@juspay/neurolink 11.25.3 → 11.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/neurolink.js CHANGED
@@ -73,6 +73,7 @@ import { getConversationMessages, storeConversationTurn, } from "./utils/convers
73
73
  // Enhanced error handling imports
74
74
  import { CircuitBreaker, ERROR_CODES, ErrorFactory, isAbortError, isRetriableError, logStructuredError, NeuroLinkError, withRetry, withTimeout, } from "./utils/errorHandling.js";
75
75
  import { hasLifecycleErrorFired, markLifecycleErrorFired, } from "./utils/lifecycleCallbacks.js";
76
+ import { interleaveTTSStream } from "./utils/ttsStream.js";
76
77
  import { resolveLifecycleTimeoutMs } from "./utils/lifecycleTimeout.js";
77
78
  import { cloneOptionsForCallIsolation } from "./utils/cloneOptions.js";
78
79
  import { coerceJsonToSchema, recoverScalarRoot, schemaAccepts, } from "./utils/json/coerce.js";
@@ -6376,7 +6377,9 @@ Current user's request: ${currentInput}`;
6376
6377
  *
6377
6378
  * // Consume the stream
6378
6379
  * for await (const chunk of result.stream) {
6379
- * process.stdout.write(chunk.content);
6380
+ * if ("content" in chunk) {
6381
+ * process.stdout.write(chunk.content);
6382
+ * }
6380
6383
  * }
6381
6384
  *
6382
6385
  * // Advanced streaming with options
@@ -6684,15 +6687,17 @@ Current user's request: ${currentInput}`;
6684
6687
  (!!(options.tools && Object.keys(options.tools).length > 0) ||
6685
6688
  this.getCustomTools().size > 0), !!(options.input?.images && options.input.images.length > 0), options
6686
6689
  .thinking?.thinkingLevel);
6687
- // TTS Mode 2 deferred: stream() emits text first, then synthesizes the
6688
- // accumulated response into a single audio chunk at end-of-stream and
6689
- // resolves `streamResult.audio` with the same TTSResult. The resolver is
6690
+ // Streaming TTS deferred: stream() always synthesizes the streamed AI
6691
+ // response when TTS is enabled, regardless of generate()'s
6692
+ // input-vs-response `useAiResponse` switch. It resolves
6693
+ // `streamResult.audio` with the aggregate TTSResult. The resolver is
6690
6694
  // plumbed explicitly through the params bag (M11: previously a
6691
6695
  // `_streamTtsResolve` cast on the caller's options object — fragile if
6692
6696
  // the same options object was reused across concurrent stream() calls).
6693
6697
  const ttsOptions = options.tts;
6694
- const wantsStreamTtsMode2 = !!(ttsOptions?.enabled && ttsOptions?.useAiResponse);
6698
+ const wantsStreamTtsMode2 = ttsOptions?.enabled === true;
6695
6699
  let resolveStreamTtsAudio;
6700
+ let streamTtsMetadata;
6696
6701
  const streamTtsAudioPromise = wantsStreamTtsMode2
6697
6702
  ? new Promise((resolve) => {
6698
6703
  resolveStreamTtsAudio = resolve;
@@ -6707,12 +6712,16 @@ Current user's request: ${currentInput}`;
6707
6712
  streamId,
6708
6713
  originalPrompt,
6709
6714
  ttsResolver: resolveStreamTtsAudio,
6715
+ ttsMetadataSink: (metadata) => {
6716
+ streamTtsMetadata = metadata;
6717
+ },
6710
6718
  })));
6711
6719
  if (streamSttTranscription) {
6712
6720
  streamResult.transcription = streamSttTranscription;
6713
6721
  }
6714
6722
  if (streamTtsAudioPromise) {
6715
6723
  streamResult.audio = streamTtsAudioPromise;
6724
+ streamResult.ttsMetadata = streamTtsMetadata;
6716
6725
  }
6717
6726
  return streamResult;
6718
6727
  }
@@ -7243,7 +7252,7 @@ Current user's request: ${currentInput}`;
7243
7252
  return result;
7244
7253
  }
7245
7254
  async runStandardStreamRequest(params) {
7246
- const { options, streamSpan, spanStartTime, startTime, hrTimeStart, streamId, originalPrompt, ttsResolver, } = params;
7255
+ const { options, streamSpan, spanStartTime, startTime, hrTimeStart, streamId, originalPrompt, ttsResolver, ttsMetadataSink, } = params;
7247
7256
  logger.debug("[NeuroLink] Running standard stream request", {
7248
7257
  streamId,
7249
7258
  provider: options.provider,
@@ -7265,6 +7274,17 @@ Current user's request: ${currentInput}`;
7265
7274
  sessionId: enhancedOptions.context?.sessionId,
7266
7275
  });
7267
7276
  const { stream: mcpStream, provider: providerName, usage: streamUsage, model: streamModel, finishReason: streamFinishReason, toolCalls: streamToolCalls, toolResults: streamToolResults, analytics: streamAnalytics, metadata: providerStreamMetadata, } = await this.createMCPStream(enhancedOptions);
7277
+ let streamedTTSResult;
7278
+ const { stream: incrementalStream, ttsMetadata: streamTtsMetadata } = await this.createIncrementalTTSStream({
7279
+ stream: mcpStream,
7280
+ ttsOptions: enhancedOptions.tts,
7281
+ providerName,
7282
+ fallbackProvider: enhancedOptions.provider,
7283
+ onComplete: (result) => {
7284
+ streamedTTSResult = result;
7285
+ },
7286
+ });
7287
+ ttsMetadataSink?.(streamTtsMetadata);
7268
7288
  const streamState = {
7269
7289
  finishReason: streamFinishReason ?? "stop",
7270
7290
  toolCalls: streamToolCalls,
@@ -7313,7 +7333,7 @@ Current user's request: ${currentInput}`;
7313
7333
  // NoOutputGeneratedError, and we want fallback to fire there.
7314
7334
  let realOutputChunks = 0;
7315
7335
  try {
7316
- for await (const chunk of mcpStream) {
7336
+ for await (const chunk of incrementalStream) {
7317
7337
  chunkCount++;
7318
7338
  const isNoOutputSentinel = chunk !== null &&
7319
7339
  typeof chunk === "object" &&
@@ -7369,26 +7389,22 @@ Current user's request: ${currentInput}`;
7369
7389
  !enhancedOptions.disableInternalFallback &&
7370
7390
  streamState.toolCalls.length === 0 &&
7371
7391
  streamState.toolResults.length === 0) {
7372
- yield* self.handleStreamFallback(metadata, streamState, originalPrompt, enhancedOptions, providerName, (content) => {
7392
+ const fallbackStream = self.handleStreamFallback(metadata, streamState, originalPrompt, enhancedOptions, providerName, (content) => {
7373
7393
  accumulatedContent += content;
7374
7394
  });
7395
+ const { stream: incrementalFallback } = await self.createIncrementalTTSStream({
7396
+ stream: fallbackStream,
7397
+ ttsOptions: enhancedOptions.tts,
7398
+ providerName,
7399
+ fallbackProvider: enhancedOptions.provider,
7400
+ ttsMetadata: streamTtsMetadata,
7401
+ onComplete: (result) => {
7402
+ streamedTTSResult = result;
7403
+ },
7404
+ });
7405
+ yield* incrementalFallback;
7375
7406
  }
7376
- // TTS Mode 2 for stream(): synthesize the accumulated response
7377
- // and yield ONE final audio chunk so callers iterating the stream
7378
- // get the audio inline; also resolve `streamResult.audio` so the
7379
- // ergonomic `await result.audio` pattern works post-iteration.
7380
- // m5: synthesis logic lives in a dedicated helper to keep this
7381
- // generator under the max-lines-per-function lint budget.
7382
- const ttsModeResult = await self.synthesizeStreamModeTwo({
7383
- ttsOptions: enhancedOptions.tts,
7384
- providerName,
7385
- fallbackProvider: enhancedOptions.provider,
7386
- accumulatedContent,
7387
- ttsResolver,
7388
- });
7389
- if (ttsModeResult.audioChunk) {
7390
- yield ttsModeResult.audioChunk;
7391
- }
7407
+ ttsResolver?.(streamedTTSResult);
7392
7408
  resolvedUsage = streamUsage;
7393
7409
  if (!resolvedUsage && streamAnalytics) {
7394
7410
  try {
@@ -7625,72 +7641,70 @@ Current user's request: ${currentInput}`;
7625
7641
  });
7626
7642
  }
7627
7643
  catch (error) {
7644
+ ttsResolver?.(undefined);
7628
7645
  if (options.disableInternalFallback) {
7629
7646
  throw error;
7630
7647
  }
7631
7648
  return this.handleStreamError(error, options, startTime, streamId, undefined, undefined);
7632
7649
  }
7633
7650
  }
7634
- /**
7635
- * TTS Mode 2 synthesis helper for the stream() pipeline.
7636
- *
7637
- * m5 — extracted from runStandardStreamRequest so the surrounding generator
7638
- * stays under the max-lines-per-function lint budget. Behaviour preserved
7639
- * exactly:
7640
- * - When Mode 2 is enabled (`tts.enabled && tts.useAiResponse`) AND the
7641
- * model produced non-empty content: synthesises one final audio buffer
7642
- * and returns it as an `audioChunk` for the caller to `yield`. Resolves
7643
- * `ttsResolver` with the `TTSResult`.
7644
- * - When Mode 2 is enabled but synthesis fails: logs a warning and resolves
7645
- * `ttsResolver` with `undefined`.
7646
- * - When Mode 2 is requested but skipped (empty content / wrong mode):
7647
- * resolves `ttsResolver` with `undefined` early so callers awaiting
7648
- * `result.audio` unblock before the surrounding `finally` cleanup
7649
- * completes (Issue 7 latency micro-opt — the finally block also resolves
7650
- * defensively, so this is a redundant early signal, not a coverage fix).
7651
- */
7652
- async synthesizeStreamModeTwo(params) {
7653
- const { ttsOptions, providerName, fallbackProvider, accumulatedContent, ttsResolver, } = params;
7654
- if (!ttsOptions?.enabled ||
7655
- !ttsOptions.useAiResponse ||
7656
- accumulatedContent.trim().length === 0) {
7657
- ttsResolver?.(undefined);
7658
- return {};
7659
- }
7660
- try {
7661
- const { TTSProcessor } = await import("./utils/ttsProcessor.js");
7662
- // ttsOptions.provider takes precedence; otherwise fall back to the
7663
- // chat provider ID ONLY when it happens to be a registered TTS handler
7664
- // (e.g. "google-ai" works for both LLM and TTS). For LLM-only IDs like
7665
- // "anthropic", we'd otherwise complete generation and then fail synth —
7666
- // surface that mismatch up front instead.
7667
- const candidate = ttsOptions.provider ?? fallbackProvider ?? providerName;
7668
- const ttsProvider = candidate && TTSProcessor.supports(candidate) ? candidate : undefined;
7669
- if (!ttsProvider) {
7670
- throw new Error(`No TTS provider resolved for stream Mode 2 (set tts.provider explicitly — chat provider "${candidate ?? "<unset>"}" is not a registered TTS handler)`);
7671
- }
7672
- const ttsResult = await TTSProcessor.synthesize(accumulatedContent, ttsProvider, ttsOptions);
7673
- ttsResolver?.(ttsResult);
7674
- return {
7675
- audioChunk: {
7676
- type: "tts_audio",
7677
- audio: {
7678
- data: ttsResult.buffer,
7679
- format: ttsResult.format,
7680
- index: 0,
7681
- isFinal: true,
7682
- cumulativeSize: ttsResult.size,
7683
- voice: ttsResult.voice,
7684
- sampleRate: ttsResult.sampleRate,
7685
- },
7686
- },
7687
- };
7651
+ /** Wrap one provider stream with incremental TTS synthesis. */
7652
+ async createIncrementalTTSStream(params) {
7653
+ const { stream, ttsOptions, providerName, fallbackProvider, onComplete } = params;
7654
+ if (!ttsOptions?.enabled) {
7655
+ onComplete(undefined);
7656
+ return { stream };
7657
+ }
7658
+ const { TTSProcessor } = await import("./utils/ttsProcessor.js");
7659
+ const concreteFallback = fallbackProvider === "auto" ? undefined : fallbackProvider;
7660
+ const candidate = ttsOptions.provider ?? concreteFallback ?? providerName;
7661
+ const ttsProvider = candidate && TTSProcessor.supports(candidate) ? candidate : undefined;
7662
+ const ttsMetadata = params.ttsMetadata ?? {
7663
+ attempted: ttsProvider !== undefined,
7664
+ success: false,
7665
+ };
7666
+ ttsMetadata.attempted = ttsProvider !== undefined;
7667
+ ttsMetadata.success = false;
7668
+ delete ttsMetadata.error;
7669
+ delete ttsMetadata.latency;
7670
+ const ttsStartedAt = Date.now();
7671
+ let completionRecorded = false;
7672
+ const recordCompletion = (result, error) => {
7673
+ if (completionRecorded) {
7674
+ return;
7675
+ }
7676
+ completionRecorded = true;
7677
+ ttsMetadata.success = error === undefined && result !== undefined;
7678
+ if (error) {
7679
+ ttsMetadata.error = error;
7680
+ }
7681
+ else {
7682
+ delete ttsMetadata.error;
7683
+ }
7684
+ ttsMetadata.latency = Date.now() - ttsStartedAt;
7685
+ onComplete(result);
7686
+ };
7687
+ if (!ttsProvider) {
7688
+ logger.warn(`[NeuroLink.stream] No TTS provider resolved for incremental streaming (set tts.provider explicitly — chat provider "${candidate ?? "<unset>"}" is not a registered TTS handler)`);
7689
+ recordCompletion(undefined);
7690
+ return { stream, ttsMetadata };
7688
7691
  }
7689
- catch (ttsError) {
7690
- logger.warn(`[NeuroLink.stream] Stream TTS Mode 2 synthesis failed: ${ttsError instanceof Error ? ttsError.message : String(ttsError)}`);
7691
- ttsResolver?.(undefined);
7692
- return {};
7692
+ return {
7693
+ stream: interleaveTTSStream({
7694
+ stream,
7695
+ provider: ttsProvider,
7696
+ options: ttsOptions,
7697
+ onComplete: recordCompletion,
7698
+ }),
7699
+ ttsMetadata,
7700
+ };
7701
+ }
7702
+ /** Prevent provider fallback streams from duplicating outer streaming TTS. */
7703
+ deferProviderStreamTTS(options) {
7704
+ if (options.tts?.enabled) {
7705
+ return { ...options, tts: undefined };
7693
7706
  }
7707
+ return options;
7694
7708
  }
7695
7709
  /**
7696
7710
  * Prepare stream options: initialize memory, MCP, retrieval, orchestration,
@@ -7966,7 +7980,7 @@ Current user's request: ${currentInput}`;
7966
7980
  context: enhancedOptions.context,
7967
7981
  });
7968
7982
  const fallbackResult = await fallbackProvider.stream({
7969
- ...enhancedOptions,
7983
+ ...this.deferProviderStreamTTS(enhancedOptions),
7970
7984
  model: fallbackRoute.model,
7971
7985
  conversationMessages,
7972
7986
  });
@@ -8395,7 +8409,7 @@ Current user's request: ${currentInput}`;
8395
8409
  }),
8396
8410
  }, "NeuroLink.createMCPStream");
8397
8411
  const poolStreamResult = await poolStreamProvider.stream({
8398
- ...options,
8412
+ ...this.deferProviderStreamTTS(options),
8399
8413
  provider: poolStreamProviderName,
8400
8414
  model: poolStreamModel,
8401
8415
  region: poolStreamRegion,
@@ -8467,7 +8481,7 @@ Current user's request: ${currentInput}`;
8467
8481
  // 🔧 FIX: Pass enhanced system prompt to real streaming
8468
8482
  // Tools will be accessed through the streamText call in executeStream
8469
8483
  const streamResult = await provider.stream({
8470
- ...options,
8484
+ ...this.deferProviderStreamTTS(options),
8471
8485
  systemPrompt: enhancedSystemPrompt, // Use enhanced prompt with tool descriptions
8472
8486
  conversationMessages,
8473
8487
  });
@@ -1738,7 +1738,28 @@ export class AnthropicProvider extends BaseProvider {
1738
1738
  }
1739
1739
  }
1740
1740
  })();
1741
- const result = await resultPromise;
1741
+ // `pump` is detached: it starts draining the engine's channel the moment
1742
+ // it is created, and `await pump` below is the only thing that adopts its
1743
+ // rejection. When resultPromise rejects, that line is never reached, so
1744
+ // pump's rejection stays unhandled — and an unhandled rejection
1745
+ // TERMINATES the consumer's process. Measured: a caller that correctly
1746
+ // try/catches a streaming error still died with ERR_UNHANDLED_REJECTION,
1747
+ // exit code 1, with no way to defend against it from outside this
1748
+ // library. The rejection carried the raw SDK error, distinct from the
1749
+ // formatted one the caller received, which is why the existing
1750
+ // `loopPromise.catch` guard below does not cover it.
1751
+ //
1752
+ // Same shape googleAiStudio/client.ts and googleVertex/client.ts already
1753
+ // use at every one of their pump sites; Anthropic was the only provider
1754
+ // missing it.
1755
+ let result;
1756
+ try {
1757
+ result = await resultPromise;
1758
+ }
1759
+ catch (error) {
1760
+ await pump.catch(() => { });
1761
+ throw error;
1762
+ }
1742
1763
  await pump;
1743
1764
  totalInput += result.usage.inputTokens;
1744
1765
  totalOutput += result.usage.outputTokens;
@@ -10,7 +10,7 @@ import type { JsonValue, UnknownRecord } from "./common.js";
10
10
  import type { Content, ImageWithAltText } from "./content.js";
11
11
  import type { ChatMessage } from "./conversation.js";
12
12
  import type { StreamNoOutputSentinel } from "./noOutputSentinel.js";
13
- import type { AdditionalMemoryUser, GenerateStopReason, ToolExecutionCaptureOptions } from "./generate.js";
13
+ import type { AdditionalMemoryUser, GenerateStopReason, TTSMetadata, ToolExecutionCaptureOptions } from "./generate.js";
14
14
  import type { AIModelProviderConfig, NeurolinkCredentials } from "./providers.js";
15
15
  import type { TTSChunk, TTSOptions, TTSResult } from "./tts.js";
16
16
  import type { STTOptions, STTResult } from "./stt.js";
@@ -169,6 +169,21 @@ export type StreamChunk = {
169
169
  /** TTS audio chunk data */
170
170
  audio: TTSChunk;
171
171
  };
172
+ /** Provider-level chunks accepted by NeuroLink's core streaming pipeline. */
173
+ export type ProviderStreamChunk = {
174
+ content: string;
175
+ } | {
176
+ type: "audio";
177
+ audio: AudioChunk;
178
+ } | {
179
+ type: "tts_audio";
180
+ audio: TTSChunk;
181
+ } | {
182
+ type: "image";
183
+ imageOutput: {
184
+ base64: string;
185
+ };
186
+ };
172
187
  export type StreamOptions = {
173
188
  /**
174
189
  * Opt this stream call into the knowledge grounding configured on the
@@ -699,13 +714,36 @@ export type StreamResult = {
699
714
  /** STT transcription result (when stt option is used) */
700
715
  transcription?: STTResult;
701
716
  /**
702
- * TTS Mode 2 result (when `tts.enabled && tts.useAiResponse`).
703
- * Resolves with the synthesized audio after the stream completes;
704
- * resolves to undefined if TTS was not enabled or synthesis failed.
705
- * The same audio is also yielded as a final chunk on `stream` for callers
706
- * that prefer to consume it inline.
717
+ * Streaming TTS result (when `tts.enabled`). `stream()` synthesizes the AI
718
+ * response incrementally; `useAiResponse` continues to select input vs
719
+ * response synthesis for non-streaming generation.
720
+ * Resolves with the synthesized audio after the caller drains `stream` to
721
+ * completion; like other stream-final fields, it remains pending while the
722
+ * lazy stream is unconsumed. It resolves with the aggregate of whatever
723
+ * segments were synthesized: a synthesis failure part-way through still
724
+ * resolves with the earlier segments rather than discarding them. It resolves
725
+ * to undefined only when no segment was produced — TTS was not enabled, no
726
+ * handler resolved for the requested provider, the model stream errored, every
727
+ * synthesis failed, or the caller stopped draining `stream` before it ended
728
+ * (an abandoned stream settles undefined rather than a partial aggregate).
729
+ * Audio is also yielded incrementally as ordered
730
+ * `tts_audio` chunks. Each chunk, including the final one, contains only its
731
+ * own buffered segment. The aggregate is a byte concatenation of those
732
+ * independently synthesized segments, so what it is depends on the format's
733
+ * framing: for frame- or sample-stream formats (`mp3`, `mpeg`, `mpga`,
734
+ * `pcm16`) it is one playable stream; for header-bearing container formats
735
+ * (`wav`, `flac`, `m4a`, `mp4`, `webm`) it is not a valid file, because each
736
+ * segment carries its own header; for `ogg`/`opus` it is a chained stream that
737
+ * some decoders read only through its first segment. Use the individual chunk
738
+ * buffers when each segment must be a valid container file.
707
739
  */
708
740
  audio?: Promise<TTSResult | undefined>;
741
+ /**
742
+ * Outcome metadata for streaming TTS synthesis. This is a mutable reference
743
+ * whose success and latency fields are finalized asynchronously; read it
744
+ * after draining `stream`.
745
+ */
746
+ ttsMetadata?: TTSMetadata;
709
747
  };
710
748
  /**
711
749
  * Enhanced provider type with stream method
@@ -34,6 +34,9 @@ export type TTSOptions = {
34
34
  /**
35
35
  * Use the AI-generated response for TTS instead of the input text
36
36
  *
37
+ * This switch applies to non-streaming generation. `stream()` always
38
+ * synthesizes the streamed AI response incrementally when TTS is enabled.
39
+ *
37
40
  * When false or undefined (default): TTS will synthesize the input text/prompt directly without calling AI generation
38
41
  * When true: TTS will synthesize the AI-generated response after generation completes
39
42
  *
@@ -78,6 +81,12 @@ export type TTSOptions = {
78
81
  play?: boolean;
79
82
  /** Override TTS provider (e.g., "elevenlabs", "openai-tts", "azure-tts") */
80
83
  provider?: TTSProviderName;
84
+ /**
85
+ * Minimum buffered text length before incremental stream synthesis flushes
86
+ * at a sentence boundary. The provider's maximum text length remains a hard
87
+ * upper bound. Defaults to 120 characters.
88
+ */
89
+ streamingBufferSize?: number;
81
90
  };
82
91
  /**
83
92
  * TTS audio result returned from generation
package/dist/types/tts.js CHANGED
@@ -60,5 +60,11 @@ export function isValidTTSOptions(options) {
60
60
  return false;
61
61
  }
62
62
  }
63
+ if (opts.streamingBufferSize !== undefined) {
64
+ if (!Number.isInteger(opts.streamingBufferSize) ||
65
+ opts.streamingBufferSize < 1) {
66
+ return false;
67
+ }
68
+ }
63
69
  return true;
64
70
  }
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * @module utils/ttsProcessor
8
8
  */
9
- import type { TTSOptions, TTSResult, TTSHandler } from "../types/index.js";
9
+ import type { TTSChunk, TTSOptions, TTSResult, TTSHandler } from "../types/index.js";
10
10
  import { ErrorCategory, ErrorSeverity } from "../constants/enums.js";
11
11
  import { NeuroLinkError } from "./errorHandling.js";
12
12
  /**
@@ -20,6 +20,12 @@ export declare const TTS_ERROR_CODES: {
20
20
  readonly SYNTHESIS_FAILED: "TTS_SYNTHESIS_FAILED";
21
21
  readonly INVALID_INPUT: "TTS_INVALID_INPUT";
22
22
  };
23
+ /** Internal signal raised after all buffered segments have been attempted. */
24
+ export declare class IncrementalTTSSynthesisError extends Error {
25
+ readonly firstError: unknown;
26
+ readonly failedSegments: readonly number[];
27
+ constructor(firstError: unknown, failedSegments: number[]);
28
+ }
23
29
  /**
24
30
  * TTS Error class for text-to-speech specific errors
25
31
  */
@@ -156,4 +162,17 @@ export declare class TTSProcessor {
156
162
  * ```
157
163
  */
158
164
  static synthesize(text: string, provider: string, options: TTSOptions): Promise<TTSResult>;
165
+ /**
166
+ * Incrementally synthesize sentence-buffered text chunks.
167
+ *
168
+ * Text is flushed at a sentence boundary after `streamingBufferSize`
169
+ * characters, or hard-split before the provider's maximum text length.
170
+ * Each segment goes through `synthesize()`, preserving the existing handler
171
+ * registry, validation, error normalization, and telemetry seam.
172
+ *
173
+ * The most recent successful audio chunk is held until another succeeds or
174
+ * the input ends, so exactly one real audio chunk carries `isFinal: true`
175
+ * without emitting a separate empty terminator chunk.
176
+ */
177
+ static synthesizeStream(textChunks: AsyncIterable<string>, provider: string, options: TTSOptions, shouldStop?: () => boolean): AsyncGenerator<TTSChunk>;
159
178
  }
@@ -22,6 +22,75 @@ export const TTS_ERROR_CODES = {
22
22
  SYNTHESIS_FAILED: "TTS_SYNTHESIS_FAILED",
23
23
  INVALID_INPUT: "TTS_INVALID_INPUT",
24
24
  };
25
+ const DEFAULT_STREAMING_BUFFER_SIZE = 120;
26
+ const SENTENCE_BOUNDARY = /[.!?]+(?:["')\]]+)?(?=\s|$)/g;
27
+ /** Internal signal raised after all buffered segments have been attempted. */
28
+ export class IncrementalTTSSynthesisError extends Error {
29
+ firstError;
30
+ failedSegments;
31
+ constructor(firstError, failedSegments) {
32
+ super(`Incremental TTS failed for ${failedSegments.length} segment${failedSegments.length === 1 ? "" : "s"}`);
33
+ this.name = "IncrementalTTSSynthesisError";
34
+ this.firstError = firstError;
35
+ this.failedSegments = [...failedSegments];
36
+ }
37
+ }
38
+ function findSentenceEnds(text) {
39
+ const ends = [];
40
+ for (const match of text.matchAll(SENTENCE_BOUNDARY)) {
41
+ ends.push((match.index ?? 0) + match[0].length);
42
+ }
43
+ return ends;
44
+ }
45
+ const HIGH_SURROGATE_START = 0xd800;
46
+ const HIGH_SURROGATE_END = 0xdbff;
47
+ const LOW_SURROGATE_START = 0xdc00;
48
+ const LOW_SURROGATE_END = 0xdfff;
49
+ /**
50
+ * Move a split index off the middle of a surrogate pair.
51
+ *
52
+ * The cap is measured in UTF-16 code units, so a hard split can land between
53
+ * the two halves of an astral character (emoji, rarer CJK). That would end one
54
+ * segment with a lone high surrogate and start the next with its low half, and
55
+ * providers receive U+FFFD instead of the character. Backing the split off by
56
+ * one code unit keeps the pair whole in the next segment.
57
+ *
58
+ * A split at index 1 is left alone: backing off would yield an empty segment
59
+ * with an unchanged remainder, and a cap that small cannot hold the pair anyway.
60
+ */
61
+ function avoidSurrogateSplit(text, splitAt) {
62
+ if (splitAt <= 1 || splitAt >= text.length) {
63
+ return splitAt;
64
+ }
65
+ const high = text.charCodeAt(splitAt - 1);
66
+ const low = text.charCodeAt(splitAt);
67
+ const splitsPair = high >= HIGH_SURROGATE_START &&
68
+ high <= HIGH_SURROGATE_END &&
69
+ low >= LOW_SURROGATE_START &&
70
+ low <= LOW_SURROGATE_END;
71
+ return splitsPair ? splitAt - 1 : splitAt;
72
+ }
73
+ function takeBufferedSegment(buffer, flushBoundary, maxTextLength, inputComplete) {
74
+ const cappedText = buffer.slice(0, maxTextLength);
75
+ const sentenceEnds = findSentenceEnds(cappedText);
76
+ let splitAt = buffer.length >= flushBoundary ? sentenceEnds.at(-1) : undefined;
77
+ if (splitAt === undefined && buffer.length >= maxTextLength) {
78
+ splitAt = sentenceEnds.at(-1) ?? maxTextLength;
79
+ }
80
+ if (splitAt === undefined && inputComplete && buffer.trim()) {
81
+ splitAt = Math.min(buffer.length, maxTextLength);
82
+ }
83
+ if (splitAt === undefined) {
84
+ return undefined;
85
+ }
86
+ splitAt = avoidSurrogateSplit(buffer, splitAt);
87
+ const segment = buffer.slice(0, splitAt).trim();
88
+ const remainder = buffer.slice(splitAt).trimStart();
89
+ if (!segment) {
90
+ return { segment: "", remainder };
91
+ }
92
+ return { segment, remainder };
93
+ }
25
94
  /**
26
95
  * TTS Error class for text-to-speech specific errors
27
96
  */
@@ -289,4 +358,102 @@ export class TTSProcessor {
289
358
  });
290
359
  }
291
360
  }
361
+ /**
362
+ * Incrementally synthesize sentence-buffered text chunks.
363
+ *
364
+ * Text is flushed at a sentence boundary after `streamingBufferSize`
365
+ * characters, or hard-split before the provider's maximum text length.
366
+ * Each segment goes through `synthesize()`, preserving the existing handler
367
+ * registry, validation, error normalization, and telemetry seam.
368
+ *
369
+ * The most recent successful audio chunk is held until another succeeds or
370
+ * the input ends, so exactly one real audio chunk carries `isFinal: true`
371
+ * without emitting a separate empty terminator chunk.
372
+ */
373
+ static async *synthesizeStream(textChunks, provider, options, shouldStop) {
374
+ const handler = this.getHandler(provider);
375
+ const maxTextLength = Math.max(1, handler?.maxTextLength ?? this.DEFAULT_MAX_TEXT_LENGTH);
376
+ const requestedBoundary = options.streamingBufferSize ?? DEFAULT_STREAMING_BUFFER_SIZE;
377
+ const flushBoundary = Math.min(Math.max(1, Math.trunc(requestedBoundary)), maxTextLength);
378
+ let buffer = "";
379
+ let chunkIndex = 0;
380
+ let cumulativeSize = 0;
381
+ let cumulativeDuration = 0;
382
+ let pendingChunk;
383
+ let segmentNumber = 0;
384
+ let firstFailure;
385
+ const failedSegments = [];
386
+ const synthesizeSegment = async (segment) => {
387
+ const currentSegment = ++segmentNumber;
388
+ try {
389
+ const result = await this.synthesize(segment, provider, options);
390
+ cumulativeSize += result.size;
391
+ cumulativeDuration += result.duration ?? 0;
392
+ return {
393
+ data: result.buffer,
394
+ format: result.format,
395
+ index: chunkIndex++,
396
+ isFinal: false,
397
+ cumulativeSize,
398
+ estimatedDuration: cumulativeDuration || undefined,
399
+ voice: result.voice,
400
+ sampleRate: result.sampleRate,
401
+ };
402
+ }
403
+ catch (error) {
404
+ if (failedSegments.length === 0) {
405
+ firstFailure = error;
406
+ }
407
+ failedSegments.push(currentSegment);
408
+ logger.warn(`[TTSProcessor] Incremental synthesis skipped a buffered segment: ${error instanceof Error ? error.message : String(error)}`);
409
+ return undefined;
410
+ }
411
+ };
412
+ for await (const textChunk of textChunks) {
413
+ if (shouldStop?.()) {
414
+ break;
415
+ }
416
+ buffer += textChunk;
417
+ while (!shouldStop?.()) {
418
+ const buffered = takeBufferedSegment(buffer, flushBoundary, maxTextLength, false);
419
+ if (!buffered) {
420
+ break;
421
+ }
422
+ buffer = buffered.remainder;
423
+ if (!buffered.segment) {
424
+ continue;
425
+ }
426
+ const chunk = await synthesizeSegment(buffered.segment);
427
+ if (chunk) {
428
+ if (pendingChunk) {
429
+ yield pendingChunk;
430
+ }
431
+ pendingChunk = chunk;
432
+ }
433
+ }
434
+ }
435
+ while (!shouldStop?.()) {
436
+ const buffered = takeBufferedSegment(buffer, flushBoundary, maxTextLength, true);
437
+ if (!buffered) {
438
+ break;
439
+ }
440
+ buffer = buffered.remainder;
441
+ if (!buffered.segment) {
442
+ continue;
443
+ }
444
+ const chunk = await synthesizeSegment(buffered.segment);
445
+ if (chunk) {
446
+ if (pendingChunk) {
447
+ yield pendingChunk;
448
+ }
449
+ pendingChunk = chunk;
450
+ }
451
+ }
452
+ if (pendingChunk) {
453
+ yield { ...pendingChunk, isFinal: true };
454
+ }
455
+ if (failedSegments.length > 0) {
456
+ throw new IncrementalTTSSynthesisError(firstFailure, failedSegments);
457
+ }
458
+ }
292
459
  }
@@ -0,0 +1,15 @@
1
+ import type { TTSChunk, TTSMetadata, TTSOptions, TTSResult } from "../types/index.js";
2
+ /**
3
+ * Preserve source-stream backpressure while interleaving incremental TTS audio.
4
+ * Source chunks are yielded before their derived audio, and TTS failures degrade
5
+ * to the unchanged source stream.
6
+ */
7
+ export declare function interleaveTTSStream<T>(params: {
8
+ stream: AsyncIterable<T>;
9
+ provider: string;
10
+ options: TTSOptions;
11
+ onComplete?: (result: TTSResult | undefined, error?: NonNullable<TTSMetadata["error"]>) => void;
12
+ }): AsyncGenerator<T | {
13
+ type: "tts_audio";
14
+ audio: TTSChunk;
15
+ }>;