UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

514 lines (513 loc) 16.2 kB
/** * Stream Handler for Voice Module * * Provides audio stream chunking, backpressure handling, and stream coordination. * * @module voice/stream-handler */ import { EventEmitter } from "events"; import { logger } from "../utils/logger.js"; /** * Default configuration */ const DEFAULT_CONFIG = { chunkDurationMs: 100, // 100ms chunks sampleRate: 16000, bytesPerSample: 2, // 16-bit mono format: "wav", highWaterMark: 64 * 1024, // 64KB bufferTimeoutMs: 5000, // 5 seconds }; /** * Chunked Audio Stream Handler * * Handles audio stream chunking with backpressure management. * * @example * ```typescript * const handler = new ChunkedAudioStream({ * chunkDurationMs: 100, * sampleRate: 16000, * }); * * handler.on('chunk', (chunk) => { * // Process audio chunk * }); * * handler.write(audioData); * handler.end(); * ``` */ export class ChunkedAudioStream extends EventEmitter { config; chunkSize; buffer; chunkIndex; timestampMs; isPaused; isEnded; pendingData; bufferTimeout; constructor(config = {}) { super(); this.config = { ...DEFAULT_CONFIG, ...config }; if (this.config.sampleRate <= 0) { throw new Error("Invalid stream configuration: sampleRate must be positive"); } if (this.config.bytesPerSample <= 0) { throw new Error("Invalid stream configuration: bytesPerSample must be positive"); } // Calculate chunk size based on duration const bytesPerMs = (this.config.sampleRate * this.config.bytesPerSample) / 1000; this.chunkSize = Math.round(this.config.chunkDurationMs * bytesPerMs); if (this.chunkSize <= 0) { throw new Error("Invalid stream configuration: chunkSize must be positive (check chunkDurationMs, sampleRate, bytesPerSample)"); } this.buffer = Buffer.alloc(0); this.chunkIndex = 0; this.timestampMs = 0; this.isPaused = false; this.isEnded = false; this.pendingData = []; this.bufferTimeout = null; } /** * Write audio data to the stream * * @param data - Audio data buffer * @returns True if more data can be written, false if backpressure */ write(data) { if (this.isEnded) { throw new Error("Cannot write to ended stream"); } // Decide backpressure state BEFORE processing so the producer can rely on // the returned flag without depending on a follow-up `write()` to flip it. // Also queue the chunk into pendingData when paused — `processData()` is // what eventually triggers `drain` via the `resume`/`drain` path, so it // must run after the resume condition is met (CodeRabbit review: // previously, processing first could emit `drain` synchronously inside // `processData()` if buffer drained below high-water mid-call, and a // producer that waited for `drain` after seeing `false` could deadlock). const willOverflow = this.buffer.length + data.length > this.config.highWaterMark; if (willOverflow) { if (!this.isPaused) { this.isPaused = true; this.emit("pause"); } this.pendingData.push(data); return false; } this.processData(data); return true; } /** * Process incoming data */ processData(data) { // Append to buffer this.buffer = Buffer.concat([this.buffer, data]); // Reset buffer timeout this.resetBufferTimeout(); // Emit chunks while we have enough data while (this.buffer.length >= this.chunkSize) { const chunkData = this.buffer.subarray(0, this.chunkSize); this.buffer = this.buffer.subarray(this.chunkSize); const chunk = { data: chunkData, index: this.chunkIndex++, isFinal: false, format: this.config.format, sampleRate: this.config.sampleRate, timestampMs: this.timestampMs, durationMs: this.config.chunkDurationMs, }; this.timestampMs += this.config.chunkDurationMs; this.emit("chunk", chunk); } // Process pending data if backpressure released if (this.isPaused && this.buffer.length < this.config.highWaterMark / 2) { this.isPaused = false; this.emit("resume"); this.emit("drain"); // Process pending data. `shift()` returns `undefined` only when the // array is empty, which the loop guard already rules out — but check // explicitly so we don't carry a non-null assertion (Issue 9). while (this.pendingData.length > 0 && !this.isPaused) { const pending = this.pendingData.shift(); if (pending === undefined) { break; } if (!this.write(pending)) { break; } } } } /** * End the stream */ end() { if (this.isEnded) { return; } this.isEnded = true; this.clearBufferTimeout(); // Drain any pending data that was buffered during backpressure for (const pending of this.pendingData) { this.buffer = Buffer.concat([this.buffer, pending]); } this.pendingData = []; // Emit final chunk with remaining data if (this.buffer.length > 0) { const durationMs = (this.buffer.length / this.config.bytesPerSample / this.config.sampleRate) * 1000; const chunk = { data: this.buffer, index: this.chunkIndex++, isFinal: true, format: this.config.format, sampleRate: this.config.sampleRate, timestampMs: this.timestampMs, durationMs, }; this.emit("chunk", chunk); } else { // Emit empty final chunk to signal end const chunk = { data: Buffer.alloc(0), index: this.chunkIndex, isFinal: true, format: this.config.format, sampleRate: this.config.sampleRate, timestampMs: this.timestampMs, durationMs: 0, }; this.emit("chunk", chunk); } this.emit("end"); this.cleanup(); } /** * Reset buffer timeout */ resetBufferTimeout() { this.clearBufferTimeout(); this.bufferTimeout = setTimeout(() => { if (this.buffer.length > 0 && !this.isEnded) { logger.warn(`[ChunkedAudioStream] Buffer timeout, forcing flush of ${this.buffer.length} bytes`); this.end(); } }, this.config.bufferTimeoutMs); } /** * Clear buffer timeout */ clearBufferTimeout() { if (this.bufferTimeout) { clearTimeout(this.bufferTimeout); this.bufferTimeout = null; } } /** * Cleanup resources */ cleanup() { this.clearBufferTimeout(); this.buffer = Buffer.alloc(0); this.pendingData = []; } /** * Get stream statistics */ getStats() { return { chunksEmitted: this.chunkIndex, bufferedBytes: this.buffer.length, pendingChunks: this.pendingData.length, totalDurationMs: this.timestampMs, isPaused: this.isPaused, isEnded: this.isEnded, }; } } /** * Stream merger for combining multiple audio streams */ export class StreamMerger extends EventEmitter { streams; config; constructor(config = {}) { super(); this.streams = new Map(); this.config = { ...DEFAULT_CONFIG, ...config }; } /** * Add a stream to merge * * @param id - Stream identifier * @returns The created stream */ addStream(id) { if (this.streams.has(id)) { throw new Error(`Stream ${id} already exists`); } const stream = new ChunkedAudioStream(this.config); stream.on("chunk", (chunk) => { this.emit("chunk", { id, chunk }); }); stream.on("end", () => { this.emit("streamEnd", id); this.streams.delete(id); if (this.streams.size === 0) { this.emit("end"); } }); stream.on("error", (error) => { this.emit("error", { id, error }); }); this.streams.set(id, stream); return stream; } /** * Remove a stream * * @param id - Stream identifier */ removeStream(id) { const stream = this.streams.get(id); if (stream) { stream.end(); this.streams.delete(id); } } /** * Write to a specific stream * * @param id - Stream identifier * @param data - Audio data */ write(id, data) { const stream = this.streams.get(id); if (!stream) { throw new Error(`Stream ${id} not found`); } return stream.write(data); } /** * End all streams */ endAll() { for (const stream of this.streams.values()) { stream.end(); } } /** * Get number of active streams */ get activeStreams() { return this.streams.size; } } /** * Stream splitter for distributing audio to multiple consumers */ export class StreamSplitter extends EventEmitter { consumers; input; constructor(config = {}) { super(); this.consumers = new Map(); this.input = new ChunkedAudioStream(config); this.input.on("chunk", (chunk) => { for (const [id, consumer] of this.consumers) { try { consumer(chunk); } catch (err) { this.emit("error", { consumerId: id, error: err instanceof Error ? err : new Error(String(err)), }); } } }); this.input.on("end", () => { this.emit("end"); }); this.input.on("error", (error) => { this.emit("error", { error }); }); } /** * Write audio data * * @param data - Audio data buffer */ write(data) { return this.input.write(data); } /** * End the stream */ end() { this.input.end(); } /** * Add a consumer * * @param id - Consumer identifier * @param handler - Chunk handler function */ addConsumer(id, handler) { if (this.consumers.has(id)) { throw new Error(`Consumer ${id} already exists`); } this.consumers.set(id, handler); } /** * Remove a consumer * * @param id - Consumer identifier */ removeConsumer(id) { this.consumers.delete(id); } /** * Get number of consumers */ get consumerCount() { return this.consumers.size; } } /** * Create an async iterable from a chunked audio stream * * @param stream - Chunked audio stream * @returns Async iterable of audio chunks */ export function streamToAsyncIterable(stream) { return { [Symbol.asyncIterator]() { const queue = []; let resolveNext = null; let rejectNext = null; let done = false; let error = null; const onChunk = (chunk) => { if (resolveNext) { resolveNext({ value: chunk, done: false }); resolveNext = null; rejectNext = null; } else { queue.push(chunk); } }; const onEnd = () => { done = true; if (resolveNext) { resolveNext({ value: undefined, done: true, }); resolveNext = null; rejectNext = null; } }; const onError = (err) => { // NEW5: surface stream errors to the consumer instead of resolving // with `done: true` (which silently terminated the for-await loop // and lost the error entirely). When a `next()` is pending, reject // it. When no `next()` is pending, store the error so the next call // throws it. error = err; if (rejectNext) { rejectNext(err); resolveNext = null; rejectNext = null; } }; stream.on("chunk", onChunk); stream.on("end", onEnd); stream.on("error", onError); // M8: track listener removal so the iterator's `return()` can detach // them when the consumer breaks early; otherwise the three listeners // hang on the EventEmitter for the stream's lifetime, leaking closures. const cleanup = () => { stream.off("chunk", onChunk); stream.off("end", onEnd); stream.off("error", onError); }; return { async next() { if (error) { throw error; } if (queue.length > 0) { // The length-guard above proves `shift()` returns a value, but // narrow explicitly to avoid the non-null assertion (Issue 9). const next = queue.shift(); if (next !== undefined) { return { value: next, done: false }; } } if (done) { return { value: undefined, done: true, }; } return new Promise((resolve, reject) => { resolveNext = resolve; rejectNext = reject; }); }, async return() { // M8: called when the consumer breaks out of `for await` early — // detach listeners to prevent leak. cleanup(); done = true; return { value: undefined, done: true, }; }, }; }, }; } /** * Create a chunked audio stream from an async iterable * * @param iterable - Async iterable of audio buffers * @param config - Stream configuration * @returns Chunked audio stream */ export async function asyncIterableToStream(iterable, config = {}) { const stream = new ChunkedAudioStream(config); // Process iterable in background. // Honour backpressure — `write()` returns false on overflow and pushes the // chunk into pendingData. Without awaiting 'drain' here, a slow consumer // would let pendingData grow unbounded for a long/live iterable. (async () => { try { for await (const data of iterable) { const ok = stream.write(data); if (!ok) { await new Promise((resolve) => stream.once("drain", resolve)); } } stream.end(); } catch (err) { stream.emit("error", err instanceof Error ? err : new Error(String(err))); } })(); return stream; } // Export main class with alias export { ChunkedAudioStream as StreamHandler };