@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/CHANGELOG.md +3 -3
- package/dist/browser/neurolink.min.js +395 -395
- package/dist/core/baseProvider.d.ts +55 -4
- package/dist/core/baseProvider.js +216 -59
- package/dist/localUsage/claudeCodeReader.js +2 -6
- package/dist/localUsage/codexReader.js +2 -6
- package/dist/localUsage/openCodeReader.js +4 -6
- package/dist/localUsage/scanWindow.d.ts +29 -0
- package/dist/localUsage/scanWindow.js +42 -0
- package/dist/neurolink.d.ts +7 -20
- package/dist/neurolink.js +99 -85
- package/dist/providers/anthropic/client.js +22 -1
- package/dist/types/stream.d.ts +44 -6
- package/dist/types/tts.d.ts +9 -0
- package/dist/types/tts.js +6 -0
- package/dist/utils/ttsProcessor.d.ts +20 -1
- package/dist/utils/ttsProcessor.js +167 -0
- package/dist/utils/ttsStream.d.ts +15 -0
- package/dist/utils/ttsStream.js +225 -0
- package/package.json +1 -1
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
|
-
*
|
|
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
|
|
6688
|
-
//
|
|
6689
|
-
//
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7636
|
-
|
|
7637
|
-
|
|
7638
|
-
|
|
7639
|
-
|
|
7640
|
-
|
|
7641
|
-
|
|
7642
|
-
|
|
7643
|
-
|
|
7644
|
-
|
|
7645
|
-
|
|
7646
|
-
|
|
7647
|
-
|
|
7648
|
-
|
|
7649
|
-
|
|
7650
|
-
|
|
7651
|
-
|
|
7652
|
-
|
|
7653
|
-
const
|
|
7654
|
-
|
|
7655
|
-
|
|
7656
|
-
|
|
7657
|
-
|
|
7658
|
-
|
|
7659
|
-
|
|
7660
|
-
|
|
7661
|
-
|
|
7662
|
-
|
|
7663
|
-
|
|
7664
|
-
|
|
7665
|
-
|
|
7666
|
-
|
|
7667
|
-
|
|
7668
|
-
|
|
7669
|
-
|
|
7670
|
-
|
|
7671
|
-
}
|
|
7672
|
-
|
|
7673
|
-
|
|
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
|
-
|
|
7690
|
-
|
|
7691
|
-
|
|
7692
|
-
|
|
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
|
-
|
|
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;
|
package/dist/types/stream.d.ts
CHANGED
|
@@ -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
|
|
703
|
-
*
|
|
704
|
-
*
|
|
705
|
-
*
|
|
706
|
-
*
|
|
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
|
package/dist/types/tts.d.ts
CHANGED
|
@@ -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
|
+
}>;
|