UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

391 lines (390 loc) • 16 kB
/** 60db workspace text-to-speech over its HTTP API. */ import { ErrorCategory, ErrorSeverity } from "../../constants/enums.js"; import { logger } from "../../utils/logger.js"; import { TTSError, TTS_ERROR_CODES } from "../../utils/ttsProcessor.js"; import { createWavFile } from "../audio-utils.js"; const RATE = 24_000; const MAX_RESPONSE_BYTES = 64 * 1024 * 1024; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; function record(value) { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("Invalid 60db response record"); } return value; } function validateRecord(value) { if (value.success === false || value.error || value.type === "error") { throw new Error("60db returned a synthesis error"); } if (value.sample_rate !== undefined && value.sample_rate !== RATE) { throw new Error("60db returned an unexpected sample rate"); } for (const encoding of [value.encoding, value.output_format]) { if (encoding !== undefined && !["LINEAR16", "PCM", "WAV"].includes(String(encoding).toUpperCase())) { throw new Error("60db returned compressed audio for a LINEAR16 request"); } } } /** Remove a PCM WAV container, including optional RIFF chunks. */ function pcmFromWav(bytes) { if (bytes.length < 12 || bytes.toString("ascii", 8, 12) !== "WAVE" || bytes.readUInt32LE(4) + 8 !== bytes.length) { throw new Error("Invalid 60db WAV container"); } let validFormat = false; let audio; let offset = 12; for (; offset + 8 <= bytes.length;) { const name = bytes.toString("ascii", offset, offset + 4); const size = bytes.readUInt32LE(offset + 4); const start = offset + 8; if (start + size > bytes.length) { throw new Error("Truncated 60db WAV chunk"); } if (name === "fmt ") { validFormat = size >= 16 && bytes.readUInt16LE(start) === 1 && bytes.readUInt16LE(start + 2) === 1 && bytes.readUInt32LE(start + 4) === RATE && bytes.readUInt32LE(start + 8) === RATE * 2 && bytes.readUInt16LE(start + 12) === 2 && bytes.readUInt16LE(start + 14) === 16; } if (name === "data") { if (audio) { throw new Error("Multiple 60db WAV data chunks"); } audio = bytes.subarray(start, start + size); } offset = start + size + (size % 2); } if (offset !== bytes.length || !validFormat || !audio?.length || audio.length % 2) { throw new Error("Expected nonempty mono PCM16 WAV at 24 kHz"); } return audio; } function audioFromRecord(value, depth = 0) { const envelope = record(value); validateRecord(envelope); const backend = envelope.backendResponse === undefined ? envelope : record(envelope.backendResponse); validateRecord(backend); const result = backend.result === undefined ? backend : record(backend.result); validateRecord(result); const encoded = backend.audio_base64 ?? result.audioContent; if (encoded === undefined) { return undefined; } if (typeof encoded !== "string" || !encoded.length || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) { throw new Error("Invalid 60db audio encoding"); } const bytes = Buffer.from(encoded, "base64"); if (bytes[0] === 123 && bytes[1] === 34) { if (depth >= 1) { throw new Error("Excessive nested 60db audio encoding"); } return audioFromRecord(JSON.parse(bytes.toString("utf8")), depth + 1); } const signature = bytes.toString("ascii", 0, 4); if (signature === "RIFF") { return pcmFromWav(bytes); } if (signature === "OggS" || signature.startsWith("ID3")) { throw new Error("Expected 60db PCM audio"); } if (!bytes.length || bytes.length % 2) { throw new Error("Incomplete 60db PCM audio"); } return bytes; } /** Buffered synthesis; NeuroLink's processor handles sentence streaming. */ export class SixtyDBTTS { maxTextLength = 5000; apiKey; voices; baseUrl; /** * Explicit endpoint supports deployments using their own API proxy. * HTTP is accepted only for a loopback host (offline tests dial * http://127.0.0.1:<port>); every other override must be HTTPS, since * requests carry `Authorization: Bearer <key>` (CodeRabbit finding on * the PR this was taken over from, applied here). The loopback list * matches proxyReplay.ts's own HTTPS-except-loopback check, including * "localhost" for a deployer's local proxy, not just the IP forms. */ constructor(apiKey, baseUrl = "https://api.60db.ai") { let parsed; try { parsed = new URL(baseUrl); } catch { throw new TTSError({ code: TTS_ERROR_CODES.INVALID_INPUT, message: `SixtyDBTTS endpoint is not a valid URL: ${baseUrl}`, category: ErrorCategory.CONFIGURATION, severity: ErrorSeverity.HIGH, retriable: false, }); } const loopback = ["127.0.0.1", "::1", "[::1]", "localhost"].includes(parsed.hostname); if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback)) { throw new TTSError({ code: TTS_ERROR_CODES.INVALID_INPUT, message: `SixtyDBTTS endpoint must use HTTPS unless it targets loopback, got: ${parsed.protocol}//${parsed.host}`, category: ErrorCategory.CONFIGURATION, severity: ErrorSeverity.HIGH, retriable: false, }); } this.baseUrl = baseUrl; this.apiKey = (apiKey ?? process.env.SIXTYDB_API_KEY ?? "").trim() || null; } isConfigured() { return this.apiKey !== null; } requireKey() { if (!this.apiKey) { throw new TTSError({ code: TTS_ERROR_CODES.PROVIDER_NOT_CONFIGURED, message: "SIXTYDB_API_KEY not configured", category: ErrorCategory.CONFIGURATION, severity: ErrorSeverity.HIGH, retriable: false, }); } return this.apiKey; } async request(path, body) { const key = this.requireKey(); const controller = new AbortController(); let reader; const timeout = setTimeout(() => { controller.abort(); void reader?.cancel().catch(() => undefined); }, 30_000); let status; try { const response = await fetch(`${this.baseUrl.replace(/\/$/, "")}${path}`, { method: body ? "POST" : "GET", redirect: "error", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", }, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }); status = response.status; if (!response.ok) { await response.body?.cancel().catch(() => undefined); const retriable = status === 408 || status === 429 || status >= 500; throw new TTSError({ code: TTS_ERROR_CODES.SYNTHESIS_FAILED, message: `60db request failed (HTTP ${status})`, category: retriable ? ErrorCategory.NETWORK : ErrorCategory.EXECUTION, severity: ErrorSeverity.HIGH, retriable, context: { status }, }); } if (!response.body) { throw new Error("60db returned no response body"); } reader = response.body.getReader(); const decoder = new TextDecoder("utf-8", { fatal: true }); let size = 0; let text = ""; let complete = false; try { while (true) { const { done, value } = await reader.read(); if (done) { break; } size += value.length; if (size > MAX_RESPONSE_BYTES) { throw new Error("60db response exceeds 64 MiB"); } text += decoder.decode(value, { stream: true }); } if (controller.signal.aborted) { throw new Error("60db response timed out"); } text += decoder.decode(); complete = true; return text; } finally { if (!complete) { await reader.cancel().catch(() => undefined); } reader.releaseLock(); } } catch (error) { if (error instanceof TTSError) { throw error; } throw new TTSError({ code: TTS_ERROR_CODES.SYNTHESIS_FAILED, message: controller.signal.aborted ? "60db request timed out after 30s" : "60db request failed", category: ErrorCategory.NETWORK, severity: ErrorSeverity.HIGH, retriable: status === undefined, context: { status }, originalError: error instanceof Error ? error : undefined, }); } finally { clearTimeout(timeout); } } async synthesize(text, options = {}) { this.requireKey(); const voice = options.voice ?? process.env.SIXTYDB_DEFAULT_VOICE; const speed = options.speed ?? 1; const format = options.format ?? "wav"; if (!text.trim() || text.length > this.maxTextLength || !voice || !UUID.test(voice) || !Number.isFinite(speed) || speed < 0.5 || speed > 2 || !["wav", "pcm16"].includes(format)) { throw new TTSError({ code: TTS_ERROR_CODES.INVALID_INPUT, message: "60db requires 1–5000 text characters, a workspace voice UUID, speed 0.5–2, and wav or pcm16 format", category: ErrorCategory.VALIDATION, retriable: false, }); } const start = Date.now(); const response = await this.request("/tts-synthesize", { text, voice_id: voice, audio_config: { audio_encoding: "LINEAR16", sample_rate_hertz: RATE }, speed, timestamp_type: "NONE", }); let pcm; try { // JSON can be pretty-printed; NDJSON records are parsed individually. let records; try { records = [JSON.parse(response)]; } catch { records = response .split(/\r?\n/) .filter((line) => line.trim()) .map((line) => JSON.parse(line)); } const chunks = records .map((value) => audioFromRecord(value)) .filter((value) => value !== undefined); pcm = Buffer.concat(chunks); if (!pcm.length) { throw new Error("60db returned no audio"); } } catch (error) { throw new TTSError({ code: TTS_ERROR_CODES.SYNTHESIS_FAILED, message: "Invalid 60db synthesis response", category: ErrorCategory.EXECUTION, severity: ErrorSeverity.HIGH, retriable: false, originalError: error instanceof Error ? error : undefined, }); } const buffer = format === "wav" ? createWavFile(pcm, RATE) : pcm; logger.info(`[SixtyDBTTS] Synthesized ${buffer.length} bytes in ${Date.now() - start}ms`); return { buffer, format, size: buffer.length, voice, sampleRate: RATE, duration: pcm.length / (RATE * 2), metadata: { latency: Date.now() - start, provider: "sixtydb" }, }; } async getVoices(languageCode) { this.requireKey(); if (!this.voices || Date.now() - this.voices.fetchedAt >= 300_000) { const voices = new Map(); for (const tier of ["quality", "fast"]) { try { const response = record(JSON.parse(await this.request(`/voices?model=${tier}`))); if (response.success !== true || !Array.isArray(response.data)) { throw new Error("Invalid voice catalog"); } for (const value of response.data) { const voice = record(value); if (typeof voice.voice_id !== "string" || !UUID.test(voice.voice_id) || typeof voice.name !== "string") { throw new Error("Invalid voice record"); } const labels = voice.labels === undefined ? {} : record(voice.labels); const language = typeof labels.language === "string" ? labels.language : ""; voices.set(voice.voice_id, { id: voice.voice_id, name: voice.name, languageCode: language, languageCodes: language ? [language] : [], gender: labels.gender === "male" || labels.gender === "female" ? labels.gender : "neutral", description: typeof voice.description === "string" ? voice.description : undefined, }); } } catch (error) { if (error instanceof TTSError) { throw error; } throw new TTSError({ code: TTS_ERROR_CODES.SYNTHESIS_FAILED, message: "Invalid 60db voice catalog", category: ErrorCategory.EXECUTION, retriable: false, }); } } this.voices = { fetchedAt: Date.now(), values: [...voices.values()] }; } if (!languageCode) { return this.voices.values; } // 60db's catalog stores bare codes ("en", "hi"); a caller may pass a // regional one ("en-US"), matching this handler's own documented // example (TTSProcessor.getVoices JSDoc). Match on the primary subtag // unconditionally (not just when one side lacks a region), so "en" // and "en-US" match both ways; this also matches "en-US" against a // catalog entry of "en-GB", which 60db's bare-code catalog never // presents in practice (AzureTTS does the narrower startsWith match, // in the other direction, since its catalog does carry regions). const wanted = languageCode.toLowerCase(); const wantedPrimary = wanted.split("-")[0]; return this.voices.values.filter((voice) => { const have = voice.languageCode.toLowerCase(); return have === wanted || have.split("-")[0] === wantedPrimary; }); } }