@juspay/neurolink 11.25.4 → 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.
@@ -72,6 +72,11 @@ export declare abstract class BaseProvider implements AIProvider {
72
72
  * that supplied `onError`).
73
73
  */
74
74
  private fireLifecycleErrorCallback;
75
+ /**
76
+ * Build the fake-stream output and apply the same incremental TTS wrapper
77
+ * used by the standard NeuroLink stream path.
78
+ */
79
+ private createFakeStreamingOutput;
75
80
  /**
76
81
  * Execute fake streaming - extracted method for reusability
77
82
  */
@@ -1,6 +1,6 @@
1
1
  import { context, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
2
2
  import { directAgentTools } from "../agent/directTools.js";
3
- import { isImageGenerationModel } from "../core/constants.js";
3
+ import { isImageGenerationModel } from "./constants.js";
4
4
  import { MiddlewareFactory } from "../middleware/factory.js";
5
5
  import { modelSupports } from "../models/modelRegistry.js";
6
6
  import { ATTR, tracers } from "../telemetry/index.js";
@@ -13,6 +13,7 @@ import { duckTypedStatusCode, extractRetryAfterMsFromError, } from "../utils/pro
13
13
  import { hasLifecycleErrorFired, markLifecycleErrorFired, } from "../utils/lifecycleCallbacks.js";
14
14
  import { resolveLifecycleTimeoutMs } from "../utils/lifecycleTimeout.js";
15
15
  import { logger } from "../utils/logger.js";
16
+ import { interleaveTTSStream } from "../utils/ttsStream.js";
16
17
  import { TimeoutError as AsyncTimeoutError, withTimeoutFn, } from "../utils/async/withTimeout.js";
17
18
  import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../utils/timeout.js";
18
19
  import { shouldDisableBuiltinTools } from "../utils/toolUtils.js";
@@ -486,6 +487,61 @@ export class BaseProvider {
486
487
  logger.warn("[lifecycle] onError callback error:", e);
487
488
  }
488
489
  }
490
+ /**
491
+ * Build the fake-stream output and apply the same incremental TTS wrapper
492
+ * used by the standard NeuroLink stream path.
493
+ */
494
+ createFakeStreamingOutput(result, options, onTTSComplete) {
495
+ const incrementalTTS = options.tts?.enabled === true;
496
+ const source = (async function* () {
497
+ if (result?.content) {
498
+ const words = result.content.split(/(\s+)/);
499
+ let buffer = "";
500
+ for (let i = 0; i < words.length; i++) {
501
+ buffer += words[i];
502
+ const shouldYield = i === words.length - 1 ||
503
+ buffer.length > 50 ||
504
+ /[.!?;,]\s*$/.test(buffer);
505
+ if (shouldYield && buffer.trim()) {
506
+ yield { content: buffer };
507
+ buffer = "";
508
+ await new Promise((resolve) => {
509
+ setTimeout(resolve, Math.random() * 9 + 1);
510
+ });
511
+ }
512
+ }
513
+ if (buffer.trim()) {
514
+ yield { content: buffer };
515
+ }
516
+ }
517
+ if (result?.imageOutput) {
518
+ yield { type: "image", imageOutput: result.imageOutput };
519
+ }
520
+ if (result?.audio && !incrementalTTS) {
521
+ yield {
522
+ type: "tts_audio",
523
+ audio: {
524
+ data: result.audio.buffer,
525
+ format: result.audio.format,
526
+ index: 0,
527
+ isFinal: true,
528
+ cumulativeSize: result.audio.size,
529
+ voice: result.audio.voice,
530
+ sampleRate: result.audio.sampleRate,
531
+ },
532
+ };
533
+ }
534
+ })();
535
+ if (!incrementalTTS || !options.tts) {
536
+ return source;
537
+ }
538
+ return interleaveTTSStream({
539
+ stream: source,
540
+ provider: options.tts.provider ?? options.provider ?? this.providerName,
541
+ options: options.tts,
542
+ onComplete: onTTSComplete,
543
+ });
544
+ }
489
545
  /**
490
546
  * Execute fake streaming - extracted method for reusability
491
547
  */
@@ -525,10 +581,10 @@ export class BaseProvider {
525
581
  skipToolPromptInjection: options.skipToolPromptInjection,
526
582
  timeout: options.timeout,
527
583
  stt: options.stt,
528
- // Forward TTS options too without this, the fake-streaming fallback
529
- // path silently drops `tts` and the resulting StreamResult never
530
- // produces a `tts_audio` chunk even when synthesis was requested.
531
- tts: options.tts,
584
+ // Streaming TTS is synthesized incrementally by
585
+ // createFakeStreamingOutput; do not let generate() perform a duplicate
586
+ // input- or whole-response synthesis first.
587
+ tts: options.tts?.enabled ? undefined : options.tts,
532
588
  };
533
589
  logger.debug(`Calling generate for fake streaming`, {
534
590
  provider: this.providerName,
@@ -545,58 +601,45 @@ export class BaseProvider {
545
601
  hasImageOutput: !!result?.imageOutput,
546
602
  timestamp: Date.now(),
547
603
  });
548
- // Create a synthetic stream from the generate result that simulates progressive delivery
549
- return {
550
- stream: (async function* () {
551
- if (result?.content) {
552
- // Split content into words for more natural streaming
553
- const words = result.content.split(/(\s+)/); // Keep whitespace
554
- let buffer = "";
555
- for (let i = 0; i < words.length; i++) {
556
- buffer += words[i];
557
- // Yield chunks of roughly 5-10 words or at punctuation
558
- const shouldYield = i === words.length - 1 || // Last word
559
- buffer.length > 50 || // Buffer getting long
560
- /[.!?;,]\s*$/.test(buffer); // End of sentence/clause
561
- if (shouldYield && buffer.trim()) {
562
- yield { content: buffer };
563
- buffer = "";
564
- // Small delay to simulate streaming (1-10ms)
565
- await new Promise((resolve) => {
566
- setTimeout(resolve, Math.random() * 9 + 1);
567
- });
568
- }
604
+ const incrementalTTS = options.tts?.enabled === true;
605
+ const ttsProvider = options.tts?.provider ?? options.provider ?? this.providerName;
606
+ const ttsStartedAt = Date.now();
607
+ let resolveAudio;
608
+ let audioSettled = false;
609
+ const audio = incrementalTTS
610
+ ? new Promise((resolve) => {
611
+ resolveAudio = resolve;
612
+ }).catch(() => undefined)
613
+ : undefined;
614
+ const ttsMetadata = incrementalTTS
615
+ ? {
616
+ attempted: TTSProcessor.supports(ttsProvider),
617
+ success: false,
618
+ }
619
+ : result?.ttsMetadata;
620
+ const onTTSComplete = incrementalTTS
621
+ ? (ttsResult, error) => {
622
+ if (audioSettled) {
623
+ return;
624
+ }
625
+ audioSettled = true;
626
+ if (ttsMetadata) {
627
+ ttsMetadata.success =
628
+ error === undefined && ttsResult !== undefined;
629
+ if (error) {
630
+ ttsMetadata.error = error;
569
631
  }
570
- // Yield all remaining content
571
- if (buffer.trim()) {
572
- yield { content: buffer };
632
+ else {
633
+ delete ttsMetadata.error;
573
634
  }
635
+ ttsMetadata.latency = Date.now() - ttsStartedAt;
574
636
  }
575
- // 🔧 CRITICAL FIX: Yield image output if present
576
- if (result?.imageOutput) {
577
- yield {
578
- type: "image",
579
- imageOutput: result.imageOutput,
580
- };
581
- }
582
- // Yield synthesized audio so callers using stream() with tts.enabled
583
- // still receive a tts_audio chunk on the fake-streaming fallback
584
- // path (matches the discriminator used by the real streaming path).
585
- if (result?.audio) {
586
- yield {
587
- type: "tts_audio",
588
- audio: {
589
- data: result.audio.buffer,
590
- format: result.audio.format,
591
- index: 0,
592
- isFinal: true,
593
- cumulativeSize: result.audio.size,
594
- voice: result.audio.voice,
595
- sampleRate: result.audio.sampleRate,
596
- },
597
- };
598
- }
599
- })(),
637
+ resolveAudio?.(ttsResult);
638
+ }
639
+ : undefined;
640
+ // Create a synthetic stream from the generate result that simulates progressive delivery
641
+ return {
642
+ stream: this.createFakeStreamingOutput(result, options, onTTSComplete),
600
643
  usage: result?.usage,
601
644
  provider: result?.provider,
602
645
  model: result?.model,
@@ -618,6 +661,8 @@ export class BaseProvider {
618
661
  // 🔧 FIX: Include analytics and evaluation from generate result
619
662
  analytics: result?.analytics,
620
663
  evaluation: result?.evaluation,
664
+ audio,
665
+ ttsMetadata,
621
666
  };
622
667
  }
623
668
  catch (error) {
@@ -945,7 +945,9 @@ export declare class NeuroLink {
945
945
  *
946
946
  * // Consume the stream
947
947
  * for await (const chunk of result.stream) {
948
- * process.stdout.write(chunk.content);
948
+ * if ("content" in chunk) {
949
+ * process.stdout.write(chunk.content);
950
+ * }
949
951
  * }
950
952
  *
951
953
  * // Advanced streaming with options
@@ -1031,25 +1033,10 @@ export declare class NeuroLink {
1031
1033
  private validateStreamRequestOptions;
1032
1034
  private maybeHandleWorkflowStreamRequest;
1033
1035
  private runStandardStreamRequest;
1034
- /**
1035
- * TTS Mode 2 synthesis helper for the stream() pipeline.
1036
- *
1037
- * m5 — extracted from runStandardStreamRequest so the surrounding generator
1038
- * stays under the max-lines-per-function lint budget. Behaviour preserved
1039
- * exactly:
1040
- * - When Mode 2 is enabled (`tts.enabled && tts.useAiResponse`) AND the
1041
- * model produced non-empty content: synthesises one final audio buffer
1042
- * and returns it as an `audioChunk` for the caller to `yield`. Resolves
1043
- * `ttsResolver` with the `TTSResult`.
1044
- * - When Mode 2 is enabled but synthesis fails: logs a warning and resolves
1045
- * `ttsResolver` with `undefined`.
1046
- * - When Mode 2 is requested but skipped (empty content / wrong mode):
1047
- * resolves `ttsResolver` with `undefined` early so callers awaiting
1048
- * `result.audio` unblock before the surrounding `finally` cleanup
1049
- * completes (Issue 7 latency micro-opt — the finally block also resolves
1050
- * defensively, so this is a redundant early signal, not a coverage fix).
1051
- */
1052
- private synthesizeStreamModeTwo;
1036
+ /** Wrap one provider stream with incremental TTS synthesis. */
1037
+ private createIncrementalTTSStream;
1038
+ /** Prevent provider fallback streams from duplicating outer streaming TTS. */
1039
+ private deferProviderStreamTTS;
1053
1040
  /**
1054
1041
  * Prepare stream options: initialize memory, MCP, retrieval, orchestration,
1055
1042
  * Ollama tool auto-disable, factory processing, and tool detection.
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
  });
@@ -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
  }