@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
TypeScript
/**
* 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>;
}