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

333 lines (332 loc) • 15.4 kB
/** * Text-to-Speech (TTS) Processing Utility * * Central orchestrator for all TTS operations across providers. * Manages provider-specific TTS handlers and audio generation. * * @module utils/ttsProcessor */ import type { TTSChunk, TTSOptions, TTSResult, TTSHandler, TTSVoice } from "../types/index.js"; import { ErrorCategory, ErrorSeverity } from "../constants/enums.js"; import { NeuroLinkError } from "./errorHandling.js"; /** * TTS-specific error codes */ export declare const TTS_ERROR_CODES: { readonly EMPTY_TEXT: "TTS_EMPTY_TEXT"; readonly TEXT_TOO_LONG: "TTS_TEXT_TOO_LONG"; readonly PROVIDER_NOT_SUPPORTED: "TTS_PROVIDER_NOT_SUPPORTED"; readonly PROVIDER_NOT_CONFIGURED: "TTS_PROVIDER_NOT_CONFIGURED"; readonly SYNTHESIS_FAILED: "TTS_SYNTHESIS_FAILED"; readonly INVALID_INPUT: "TTS_INVALID_INPUT"; }; /** Internal signal raised after all buffered segments have been attempted. */ export declare class IncrementalTTSSynthesisError extends Error { readonly firstError: unknown; readonly failedSegments: readonly number[]; constructor(firstError: unknown, failedSegments: number[]); } /** * TTS Error class for text-to-speech specific errors */ export declare class TTSError extends NeuroLinkError { constructor(options: { code: string; message: string; category?: ErrorCategory; severity?: ErrorSeverity; retriable?: boolean; context?: Record<string, unknown>; originalError?: Error; }); } /** * TTS processor class for orchestrating text-to-speech operations * * Follows the same pattern as CSVProcessor, ImageProcessor, and PDFProcessor. * Provides a unified interface for TTS generation across multiple providers. * * @example * ```typescript * // Register a handler * TTSProcessor.registerHandler('google-ai', googleAIHandler); * * // Check if provider is supported * if (TTSProcessor.supports('google-ai')) { * // Provider is registered * } * ``` */ export declare class TTSProcessor { /** * Handler registry mapping provider names to TTS handlers * * @private */ private static readonly registry; /** * Default maximum text length for TTS synthesis (in bytes) * * Providers can override this value by specifying the `maxTextLength` property * in their respective `TTSHandler` implementation. If not specified, this default * value will be used. * * @private */ private static readonly DEFAULT_MAX_TEXT_LENGTH; /** * Register a TTS handler for a specific provider * * Allows providers to register their TTS implementation at runtime. * * @param providerName - Provider identifier (e.g., 'google-ai', 'openai') * @param handler - TTS handler implementation * * @example * ```typescript * const googleHandler: TTSHandler = { * synthesize: async (text, options) => { ... }, * getVoices: async (languageCode) => { ... }, * isConfigured: () => true * }; * * TTSProcessor.registerHandler('google-ai', googleHandler); * ``` */ static registerHandler(providerName: string, handler: TTSHandler): void; /** * Get a registered TTS handler by provider name. * * Exposed publicly so module-level auto-registration code can reuse an * already-registered primary handler when backfilling its aliases — * see `src/lib/voice/index.ts:registerDefaultTTSHandlers`. * * @param providerName - Provider identifier * @returns Handler instance or undefined if not registered */ static getHandler(providerName: string): TTSHandler | undefined; /** * List the names of all registered providers. */ static listProviders(): string[]; /** * Removes every registered TTS handler. Primarily for test isolation — * production code should not need to call this. */ static clearHandlers(): void; /** * Check if a provider is supported (has a registered TTS handler) * * @param providerName - Provider identifier * @returns True if handler is registered * * @example * ```typescript * if (TTSProcessor.supports('google-ai')) { * console.log('Google AI TTS is supported'); * } * ``` */ static supports(providerName: string): boolean; /** * List the voices a registered provider offers. * * The counterpart to `synthesize()` for discovery: a caller cannot pass * `TTSOptions.voice` without first knowing what the provider will accept, * and `getVoices` is optional on `TTSHandler`, so asking the handler * directly means every caller re-implements the same two guards. Both * failures are reported as typed `TTSError`s rather than a `TypeError` on * an absent member. * * `languageCode` is passed through verbatim; each handler decides what * filtering it means. Google and Azure query their APIs with it, OpenAI's * voice list is fixed and ignores it. * * @param providerName - Provider identifier, resolved case-insensitively * @param options - Optional language filter * @returns The provider's voices * @throws TTSError if the provider is not registered or cannot list voices * * @example * ```typescript * const voices = await TTSProcessor.getVoices("google-ai", { * languageCode: "en-US", * }); * ``` */ static getVoices(providerName: string, options?: { languageCode?: string; }): Promise<TTSVoice[]>; /** * Synthesize speech from text using a registered TTS provider * * Orchestrates the text-to-speech generation process: * 1. Validates input text (not empty, within length limits) * 2. Looks up the provider handler * 3. Verifies provider configuration * 4. Delegates synthesis to the provider (timeout handled by provider) * 5. Enriches result with metadata * * **Timeout Handling:** * Timeouts are enforced by individual provider implementations (see TTSHandler interface). * Providers typically use 30-second timeouts via `withTimeout()` utility or * provider-specific timeout mechanisms (e.g., Google Cloud client timeout). * * @param text - Text to convert to speech * @param provider - Provider identifier * @param options - TTS configuration options * @returns Audio result with buffer and metadata * @throws TTSError if validation fails or provider not supported/configured * * @example * ```typescript * const result = await TTSProcessor.synthesize("Hello, world!", "google-ai", { * voice: "en-US-Neural2-C", * format: "mp3", * speed: 1.0 * }); * * console.log(`Generated ${result.size} bytes of ${result.format} audio`); * // Save to file or play the audio buffer * ``` */ static synthesize(text: string, provider: string, options: TTSOptions): Promise<TTSResult>; /** * Normalize a provider failure into the public `TTSError` shape. * * Extracted from `synthesize()` so the native streaming path can shape its * transport errors identically. A raw provider error reaching the public * surface unwrapped loses `retriable` (and the provider-qualified message), * which is exactly what a caller keying retry logic off * `ttsMetadata.error.retriable` reads. */ private static toSynthesisError; /** * Open the `tts.synthesize` span that `synthesize()` opens, so a segment * served by a handler's native stream emits the same telemetry a buffered * segment does. */ private static startSynthesisSpan; private static finishSynthesisSpan; /** * `TTSHandler.synthesizeStream` is declared `unknown`, so a handler may * carry anything at all under that name — including a legacy member of an * unrelated shape. The native capability is discovered structurally instead: * the member must be callable, and what it returns must be async-iterable. * * Discovery reads consumer-controlled properties, and a property read can * itself execute user code (a Proxy trap, a throwing getter) — the member on * the handler and the well-known symbol on what it returns alike. Every one * of those reads therefore happens inside a try: `resolveNativeStream` * guards the whole preflight in one region, and `cancelStream`/ * `releaseIterator` in `streamCancellation.ts` guard the symbol read they * each perform on the unwind path. */ private static isCallableMember; private static isNativeStream; /** * Coerce one fragment yielded by a handler's native stream into the fields * this processor forwards, or `undefined` when the fragment is not audio. * * `TTSHandler.synthesizeStream` is declared `unknown` because ANY narrower * type rejects some existing consumer that already carries a member of that * name — a Critical Rule 5 break. The type therefore checks nothing at all, * and every check happens at runtime instead. Here that means: a * fragment must carry a non-empty binary payload, and a reported `format` is * honoured only when it names a real audio format, falling back to the * requested one. The caller skips an `undefined` result exactly as it skips * a zero-length transport read. */ private static normalizeNativeChunk; /** * Ask a handler for a native stream for one segment, or `undefined` to serve * the segment from `synthesize()`. * * `undefined` is the documented "not incrementally deliverable" signal. A * handler that throws instead, or hands back something that is not * async-iterable, is buggy — but that is a reason to serve the segment from * the buffered path, not to lose it. * * **Every failure this method anticipates answers `undefined` instead of * throwing, and the caller's catch backstops what no code that must * describe a hostile thrown value can rule out.** Everything it does is a question ABOUT the * handler, asked before any of the segment's work has begun, and every one * of those questions can run consumer code: reading `synthesizeStream` or * `isConfigured` can hit an accessor or a `Proxy` trap, calling * `isConfigured()` and calling the member run handler code outright, and * reading `Symbol.asyncIterator` off whatever comes back can hit a getter or * a trap of its own. A throw from ANY of them means the same thing — the * capability could not be established — so they all sit inside the single * guarded region below and every failure answers `undefined`. The segment * then goes to `synthesize()`, which re-runs the identical preflight inside * its own `tts.synthesize` span and normalizes whatever it raises, exactly * as it did before native streaming existed. Reporting a preflight failure * from here instead would open a second span for one segment, or drop a * segment the buffered path can still serve. * * Two ordering rules make that equivalence exact: * * - The member is read FIRST and exactly ONCE per segment. An accessor- * defined member runs consumer code on every read, so a second read can * answer differently from the one that was tested — and reading it before * `isConfigured()` keeps a handler that does not offer the capability at * all on precisely the call sequence it had before this method existed. * - `isConfigured()` runs only once a callable member has been found, which * is the only case where this method is about to invoke the handler. * * The one measured departure from `origin/release` is a call count, not an * outcome: a segment that starts down the native path and falls back calls * the handler's `isConfigured()` twice — here, and again inside * `synthesize()`. That is inherent to attempting native delivery at all and * predates this method's current shape; `isConfigured()` is specified as a * configuration predicate, and for any implementation that behaves as one — * a pure predicate — every observable of such a segment (chunks, spans, * error code and `retriable`) is identical either way. A handler whose * answer CHANGES between the two calls changes the outcome with it: the * second answer, taken on the path that actually synthesizes, decides. */ private static resolveNativeStream; /** * Incrementally synthesize sentence-buffered text chunks. * * Text is flushed at a sentence boundary after `streamingBufferSize` * characters, or hard-split before the provider's maximum text length. * * A segment is served by the handler's `synthesizeStream()` when it offers * one and returns a stream, and by `synthesize()` otherwise — including * when the native stream produces no deliverable audio at all, and including * every way capability discovery itself can fail. The preflight reads and * calls that decide whether a native stream exists all sit inside one * guarded region in `resolveNativeStream`, with the call site's own catch * backstopping it, so a handler that misbehaves while being ASKED lands on * the buffered path rather than costing the segment. That is what makes the * next sentence true of every segment rather than only of the ones that got * that far. * * Either way the segment keeps the same handler registry, validation, error * normalization and `tts.synthesize` telemetry seam — exactly one span per * segment, opened by whichever path served it — cancellation included: a * segment whose stream is still in flight when the consumer stops records * its span from the unwind path rather than dropping it. Failures that * originate in the native segment's own work, once a stream has been * established, are a different case and keep the shaped failed-segment * semantics every synthesis failure has. * * Provider-reported indexes, cumulative sizes and finality are discarded and * recomputed globally. A native fragment is dropped unless it carries a * non-empty binary payload, so no native read reaches the consumer as an * empty chunk; the buffered path is unfiltered and forwards whatever * `synthesize()` returns, so a handler that produces a zero-byte buffer * still yields an empty chunk and a repeated `cumulativeSize`. The most * recent successful audio chunk is held until another succeeds or the input * ends, so exactly one real audio chunk carries `isFinal: true` without * emitting a separate empty terminator chunk. * * Segment production and per-segment synthesis are deliberately inline * rather than nested async generators: each additional generator layer costs * every chunk several microtask turns, which is directly observable at * `NeuroLink.stream()` as audio interleaving one text chunk later than it * does without native streaming. */ static synthesizeStream(textChunks: AsyncIterable<string>, provider: string, options: TTSOptions, shouldStop?: () => boolean): AsyncGenerator<TTSChunk>; }