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

263 lines (262 loc) • 9.95 kB
import { NeuroLinkError } from "./errorHandling.js"; import { sanitizeErrorCause } from "./logSanitize.js"; import { logger } from "./logger.js"; import { IncrementalTTSSynthesisError, TTS_ERROR_CODES, TTSProcessor, } from "./ttsProcessor.js"; import { TimeoutError as AsyncTimeoutError } from "./async/withTimeout.js"; import { cancelStream } from "./streamCancellation.js"; function getStreamingTTSErrorDetails(error) { const incrementalFailure = error instanceof IncrementalTTSSynthesisError ? error : undefined; const cause = incrementalFailure?.firstError ?? error; const safeMessage = sanitizeErrorCause(cause).message; let detail; if (cause instanceof AsyncTimeoutError) { detail = { code: TTS_ERROR_CODES.SYNTHESIS_FAILED, message: safeMessage, retriable: true, }; } else if (cause instanceof NeuroLinkError) { detail = { code: cause.code, message: safeMessage, retriable: cause.retriable, }; } else { detail = { code: TTS_ERROR_CODES.SYNTHESIS_FAILED, message: safeMessage, }; } const failedSegments = incrementalFailure?.failedSegments; const count = failedSegments?.length ?? 1; const positions = failedSegments?.join(", ") ?? "unknown"; return { ...detail, message: `Incremental TTS failed for ${count} segment${count === 1 ? "" : "s"} (segment${count === 1 ? "" : "s"} ${positions}): ${detail.message}`, }; } class AsyncTextQueue { values = []; waiters = []; ended = false; failure; [Symbol.asyncIterator]() { return this; } next() { if (this.values.length > 0) { const value = this.values.shift(); if (value !== undefined) { return Promise.resolve({ value, done: false }); } } if (this.failure !== undefined) { return Promise.reject(this.failure); } if (this.ended) { return Promise.resolve({ value: undefined, done: true }); } return new Promise((resolve, reject) => { this.waiters.push({ resolve, reject }); }); } push(value) { if (this.ended || this.failure !== undefined) { return; } const waiter = this.waiters.shift(); if (waiter) { waiter.resolve({ value, done: false }); return; } this.values.push(value); } end() { if (this.ended || this.failure !== undefined) { return; } this.ended = true; for (const waiter of this.waiters.splice(0)) { waiter.resolve({ value: undefined, done: true }); } } fail(error) { if (this.ended || this.failure !== undefined) { return; } this.failure = error; for (const waiter of this.waiters.splice(0)) { waiter.reject(error); } } } function sourceEvent(iterator) { return iterator.next().then((result) => ({ kind: "source", result }), (error) => ({ kind: "source-error", error })); } function audioEvent(iterator) { return iterator.next().then((result) => ({ kind: "audio", result }), (error) => ({ kind: "audio-error", error })); } function textFromChunk(chunk) { if (chunk && typeof chunk === "object" && "content" in chunk && typeof chunk.content === "string" && chunk.content.length > 0) { return chunk.content; } return undefined; } function aggregateTTSChunks(chunks) { const last = chunks.at(-1); if (!last) { return undefined; } const buffer = Buffer.concat(chunks.map((chunk) => chunk.data)); return { buffer, format: last.format, size: buffer.length, duration: last.estimatedDuration, voice: last.voice, sampleRate: last.sampleRate, }; } /** * Upper bound on how long stream teardown may wait for upstream iterators to * acknowledge `.return()`. Closing an iterator is bookkeeping, not work, so a * responsive upstream finishes far inside this; an unresponsive one is exactly * the case that must not block the consumer. The timer is unref'd so a pending * grace period never by itself keeps the process alive. */ const RELEASE_GRACE_MS = 5_000; function releaseGraceTimer() { return new Promise((resolve) => { const timer = setTimeout(resolve, RELEASE_GRACE_MS); timer.unref?.(); }); } /** * Preserve source-stream backpressure while interleaving incremental TTS audio. * Source chunks are yielded before their derived audio, and TTS failures degrade * to the unchanged source stream. */ export async function* interleaveTTSStream(params) { const { stream, provider, options, onComplete } = params; if (!TTSProcessor.supports(provider)) { try { for await (const chunk of stream) { yield chunk; } } finally { onComplete?.(undefined); } return; } const textQueue = new AsyncTextQueue(); const sourceIterator = stream[Symbol.asyncIterator](); let cancelled = false; const audioIterator = TTSProcessor.synthesizeStream(textQueue, provider, options, () => cancelled)[Symbol.asyncIterator](); const audioChunks = []; let nextSource = sourceEvent(sourceIterator); let nextAudio = audioEvent(audioIterator); let completed = false; let preferAudio = false; try { while (nextSource || nextAudio) { const event = nextSource && nextAudio ? await Promise.race(preferAudio ? [nextAudio, nextSource] : [nextSource, nextAudio]) : nextSource ? await nextSource : nextAudio ? await nextAudio : undefined; if (!event) { break; } if (event.kind === "source-error") { textQueue.fail(event.error); throw event.error; } if (event.kind === "audio-error") { textQueue.end(); const error = getStreamingTTSErrorDetails(event.error); logger.warn(`[TTSProcessor] Incremental stream disabled after an audio error: ${event.error instanceof Error ? event.error.message : String(event.error)}`); nextAudio = undefined; preferAudio = false; completed = true; onComplete?.(aggregateTTSChunks(audioChunks), error); continue; } if (event.kind === "source") { if (event.result.done) { nextSource = undefined; textQueue.end(); preferAudio = true; continue; } const text = textFromChunk(event.result.value); if (text) { textQueue.push(text); } nextSource = sourceEvent(sourceIterator); preferAudio = true; yield event.result.value; continue; } if (event.result.done) { nextAudio = undefined; completed = true; onComplete?.(aggregateTTSChunks(audioChunks)); continue; } audioChunks.push(event.result.value); nextAudio = audioEvent(audioIterator); preferAudio = false; yield { type: "tts_audio", audio: event.result.value }; } } finally { if (!completed) { cancelled = true; // Tell the upstream chain out of band, before asking politely via // `.return()` below. When the consumer breaks mid-pull every wrapper // beneath is parked inside an `await` and cannot process a queued // `return()`, so this side channel is the only thing that actually // reaches — and closes — the provider stream at the bottom. cancelStream(stream); // The audio side has its own wrapper chain. Native TTS can be parked in a // response-body read even after text ingestion stops, so its cancel hook // must be reached directly rather than waiting for queued `.return()`. cancelStream(audioIterator); } textQueue.end(); const releases = []; if (nextSource && sourceIterator.return) { releases.push(Promise.resolve().then(() => sourceIterator.return?.(undefined))); } if (nextAudio && audioIterator.return) { releases.push(Promise.resolve().then(() => audioIterator.return?.(undefined))); } // Cleanup must not be able to outlive the consumer that abandoned this // stream. `.return()` on an async generator parked inside an `await` is // queued behind the in-flight `next()` and cannot interrupt it, so when an // upstream pull never settles these promises settle *never* — not late. // Awaiting them unbounded turned a consumer's `break` into a permanent // hang: the caller's `for await` never returned, with no error and no way // to defend against it from outside this module. // // The releases are still issued, and still propagate normally whenever the // upstream is responsive — which is every case where awaiting them was // doing anything useful. We only stop making the consumer's exit depend on // an upstream that may never answer. await Promise.race([Promise.allSettled(releases), releaseGraceTimer()]); if (!completed) { onComplete?.(undefined); } } }