@juspay/neurolink 12.6.1 → 12.7.1
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 +2 -3
- package/dist/browser/neurolink.min.js +381 -381
- package/dist/core/evaluationProviders.d.ts +1 -1
- package/dist/core/evaluationProviders.js +1 -1
- package/dist/proxy/clientAttribution.d.ts +43 -0
- package/dist/proxy/clientAttribution.js +40 -14
- package/dist/types/common.d.ts +87 -0
- package/dist/types/voice.d.ts +13 -0
- package/dist/utils/ttsProcessor.d.ts +130 -5
- package/dist/utils/ttsProcessor.js +642 -97
- package/dist/utils/ttsStream.js +4 -0
- package/dist/voice/providers/OpenAITTS.d.ts +26 -8
- package/dist/voice/providers/OpenAITTS.js +327 -85
- package/package.json +1 -1
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* @module utils/ttsProcessor
|
|
8
8
|
*/
|
|
9
9
|
import { logger } from "./logger.js";
|
|
10
|
+
import { VALID_AUDIO_FORMATS } from "../types/index.js";
|
|
10
11
|
import { ErrorCategory, ErrorSeverity } from "../constants/enums.js";
|
|
11
12
|
import { NeuroLinkError } from "./errorHandling.js";
|
|
12
13
|
import { HandlerRegistry } from "../core/handlerRegistry.js";
|
|
14
|
+
import { attachStreamCancel, cancelStream, releaseIterator, } from "./streamCancellation.js";
|
|
13
15
|
import { SpanSerializer, SpanType, SpanStatus, getMetricsAggregator, } from "../observability/index.js";
|
|
14
16
|
/**
|
|
15
17
|
* TTS-specific error codes
|
|
@@ -35,6 +37,91 @@ export class IncrementalTTSSynthesisError extends Error {
|
|
|
35
37
|
this.failedSegments = [...failedSegments];
|
|
36
38
|
}
|
|
37
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Converts an arbitrary thrown value to a loggable string without running
|
|
42
|
+
* code the value controls unguarded: `.message` can be a throwing accessor,
|
|
43
|
+
* and `String()` can hit a hostile `toString`/`Symbol.toPrimitive` or a
|
|
44
|
+
* null-prototype object. A logging or normalization path must never become
|
|
45
|
+
* a new way for a handler to break the stream.
|
|
46
|
+
*/
|
|
47
|
+
function safeErrorMessage(error) {
|
|
48
|
+
try {
|
|
49
|
+
if (error instanceof Error) {
|
|
50
|
+
return String(error.message);
|
|
51
|
+
}
|
|
52
|
+
return String(error);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return "[unprintable error]";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* `instanceof` is not a safe question to ask of a value a provider threw.
|
|
60
|
+
* The check performs a prototype lookup, and a `Proxy` with a throwing
|
|
61
|
+
* `getPrototypeOf` trap — or a revoked one — detonates on it, which turns the
|
|
62
|
+
* classification itself into a second way for a handler to break the stream.
|
|
63
|
+
* A value that will not answer the question is not the class being asked about.
|
|
64
|
+
*/
|
|
65
|
+
function safeInstanceOf(value, ctor) {
|
|
66
|
+
try {
|
|
67
|
+
return value instanceof ctor;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Reduce a caught value to an `Error` that is safe to hand onwards as
|
|
75
|
+
* `originalError`.
|
|
76
|
+
*
|
|
77
|
+
* `NeuroLinkError`'s constructor reads `.stack` and `.message` off that option
|
|
78
|
+
* without guarding them, so an `Error` carrying a throwing accessor detonates
|
|
79
|
+
* *inside* the shaper — from within the `catch` that exists to contain it,
|
|
80
|
+
* where nothing re-catches it. Forwarding is therefore conditional on the
|
|
81
|
+
* reads succeeding here, where a throw is contained; anything that fails is
|
|
82
|
+
* replaced by a plain carrier holding the already-safe message and owning no
|
|
83
|
+
* accessors of its own. Shared error infrastructure is left exactly as it is:
|
|
84
|
+
* the TTS boundary owes it a value it has proven safe.
|
|
85
|
+
*/
|
|
86
|
+
function toSafeOriginalError(error, safeMessage) {
|
|
87
|
+
if (!safeInstanceOf(error, Error)) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
// Exactly the reads the structured-error constructor performs, forced
|
|
92
|
+
// here rather than discovered there.
|
|
93
|
+
String(error.name);
|
|
94
|
+
String(error.message);
|
|
95
|
+
String(error.stack);
|
|
96
|
+
return error;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return new Error(safeMessage);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* A value may pass `safeInstanceOf` by LYING — a `Proxy` whose
|
|
104
|
+
* `getPrototypeOf` answers `TTSError.prototype` while every `get` detonates.
|
|
105
|
+
* Passing one through as "already shaped" hands the hostile value to every
|
|
106
|
+
* downstream consumer. Trust the claim only when the reads those consumers
|
|
107
|
+
* perform succeed here, where a throw is contained.
|
|
108
|
+
*/
|
|
109
|
+
function isReadableTTSError(value) {
|
|
110
|
+
if (!safeInstanceOf(value, TTSError)) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
String(value.name);
|
|
115
|
+
String(value.message);
|
|
116
|
+
String(value.code);
|
|
117
|
+
void value.retriable;
|
|
118
|
+
String(value.stack);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
38
125
|
function findSentenceEnds(text) {
|
|
39
126
|
const ends = [];
|
|
40
127
|
for (const match of text.matchAll(SENTENCE_BOUNDARY)) {
|
|
@@ -108,6 +195,24 @@ export class TTSError extends NeuroLinkError {
|
|
|
108
195
|
this.name = "TTSError";
|
|
109
196
|
}
|
|
110
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* Build the shaped error, retrying without the original if attaching it
|
|
200
|
+
* throws.
|
|
201
|
+
*
|
|
202
|
+
* `toSafeOriginalError` proves the reads succeed, but a one-shot accessor can
|
|
203
|
+
* answer one way when it is checked and another when the structured-error
|
|
204
|
+
* constructor reads it. Nothing about a caught value is worth losing the
|
|
205
|
+
* shaped failure over, so the retry drops it and keeps the code, the
|
|
206
|
+
* provider-qualified message and the `retriable` flag.
|
|
207
|
+
*/
|
|
208
|
+
function buildTTSError(details, originalError) {
|
|
209
|
+
try {
|
|
210
|
+
return new TTSError({ ...details, originalError });
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return new TTSError(details);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
111
216
|
/**
|
|
112
217
|
* TTS processor class for orchestrating text-to-speech operations
|
|
113
218
|
*
|
|
@@ -333,127 +438,567 @@ export class TTSProcessor {
|
|
|
333
438
|
}
|
|
334
439
|
catch (err) {
|
|
335
440
|
// Record error span
|
|
336
|
-
const endedSpan = SpanSerializer.endSpan(span, SpanStatus.ERROR,
|
|
441
|
+
const endedSpan = SpanSerializer.endSpan(span, SpanStatus.ERROR, safeErrorMessage(err));
|
|
337
442
|
getMetricsAggregator().recordSpan(endedSpan);
|
|
338
443
|
// 9. Comprehensive error handling
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
444
|
+
throw this.toSynthesisError(err, provider, text, options);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Normalize a provider failure into the public `TTSError` shape.
|
|
449
|
+
*
|
|
450
|
+
* Extracted from `synthesize()` so the native streaming path can shape its
|
|
451
|
+
* transport errors identically. A raw provider error reaching the public
|
|
452
|
+
* surface unwrapped loses `retriable` (and the provider-qualified message),
|
|
453
|
+
* which is exactly what a caller keying retry logic off
|
|
454
|
+
* `ttsMetadata.error.retriable` reads.
|
|
455
|
+
*/
|
|
456
|
+
static toSynthesisError(error, provider, text, options) {
|
|
457
|
+
// Already-structured errors pass through untouched. Guarded because this
|
|
458
|
+
// is the first thing done with a value the provider chose: a proxy that
|
|
459
|
+
// traps its prototype lookup used to fail the whole shaper right here,
|
|
460
|
+
// costing the segment its `retriable` flag.
|
|
461
|
+
if (isReadableTTSError(error)) {
|
|
462
|
+
return error;
|
|
463
|
+
}
|
|
464
|
+
const errorMessage = error ? safeErrorMessage(error) : "Unknown error";
|
|
465
|
+
logger.error(`[TTSProcessor] Synthesis failed for provider "${provider}": ${errorMessage}`);
|
|
466
|
+
return buildTTSError({
|
|
467
|
+
code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
|
|
468
|
+
message: `TTS synthesis failed for provider "${provider}": ${errorMessage}`,
|
|
469
|
+
category: ErrorCategory.EXECUTION,
|
|
470
|
+
severity: ErrorSeverity.HIGH,
|
|
471
|
+
retriable: true,
|
|
472
|
+
context: {
|
|
473
|
+
provider,
|
|
474
|
+
textLength: text.trim().length,
|
|
475
|
+
options,
|
|
476
|
+
},
|
|
477
|
+
}, toSafeOriginalError(error, errorMessage));
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Open the `tts.synthesize` span that `synthesize()` opens, so a segment
|
|
481
|
+
* served by a handler's native stream emits the same telemetry a buffered
|
|
482
|
+
* segment does.
|
|
483
|
+
*/
|
|
484
|
+
static startSynthesisSpan(provider, options) {
|
|
485
|
+
return SpanSerializer.createSpan(SpanType.TTS, "tts.synthesize", {
|
|
486
|
+
"tts.operation": "synthesize",
|
|
487
|
+
"tts.provider": provider,
|
|
488
|
+
"tts.voice": options.voice,
|
|
489
|
+
"tts.format": options.format,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
static finishSynthesisSpan(span, status, message) {
|
|
493
|
+
getMetricsAggregator().recordSpan(SpanSerializer.endSpan(span, status, message));
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* `TTSHandler.synthesizeStream` is declared `unknown`, so a handler may
|
|
497
|
+
* carry anything at all under that name — including a legacy member of an
|
|
498
|
+
* unrelated shape. The native capability is discovered structurally instead:
|
|
499
|
+
* the member must be callable, and what it returns must be async-iterable.
|
|
500
|
+
*
|
|
501
|
+
* Discovery reads consumer-controlled properties, and a property read can
|
|
502
|
+
* itself execute user code (a Proxy trap, a throwing getter) — the member on
|
|
503
|
+
* the handler and the well-known symbol on what it returns alike. Every one
|
|
504
|
+
* of those reads therefore happens inside a try: `resolveNativeStream`
|
|
505
|
+
* guards the whole preflight in one region, and `cancelStream`/
|
|
506
|
+
* `releaseIterator` in `streamCancellation.ts` guard the symbol read they
|
|
507
|
+
* each perform on the unwind path.
|
|
508
|
+
*/
|
|
509
|
+
static isCallableMember(value) {
|
|
510
|
+
return typeof value === "function";
|
|
511
|
+
}
|
|
512
|
+
static isNativeStream(value) {
|
|
513
|
+
if (value === null ||
|
|
514
|
+
(typeof value !== "object" && typeof value !== "function")) {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
const iterable = value;
|
|
518
|
+
return typeof iterable[Symbol.asyncIterator] === "function";
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Coerce one fragment yielded by a handler's native stream into the fields
|
|
522
|
+
* this processor forwards, or `undefined` when the fragment is not audio.
|
|
523
|
+
*
|
|
524
|
+
* `TTSHandler.synthesizeStream` is declared `unknown` because ANY narrower
|
|
525
|
+
* type rejects some existing consumer that already carries a member of that
|
|
526
|
+
* name — a Critical Rule 5 break. The type therefore checks nothing at all,
|
|
527
|
+
* and every check happens at runtime instead. Here that means: a
|
|
528
|
+
* fragment must carry a non-empty binary payload, and a reported `format` is
|
|
529
|
+
* honoured only when it names a real audio format, falling back to the
|
|
530
|
+
* requested one. The caller skips an `undefined` result exactly as it skips
|
|
531
|
+
* a zero-length transport read.
|
|
532
|
+
*/
|
|
533
|
+
static normalizeNativeChunk(fragment, options) {
|
|
534
|
+
if (fragment === null || typeof fragment !== "object") {
|
|
535
|
+
return undefined;
|
|
536
|
+
}
|
|
537
|
+
const candidate = fragment;
|
|
538
|
+
const payload = candidate.data;
|
|
539
|
+
let data;
|
|
540
|
+
if (Buffer.isBuffer(payload)) {
|
|
541
|
+
data = payload;
|
|
542
|
+
}
|
|
543
|
+
else if (payload instanceof Uint8Array) {
|
|
544
|
+
data = Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength);
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
return undefined;
|
|
548
|
+
}
|
|
549
|
+
// A zero-length transport read is not audio. Emitting it would put an
|
|
550
|
+
// empty chunk in front of the consumer and repeat `cumulativeSize`.
|
|
551
|
+
if (data.length === 0) {
|
|
552
|
+
return undefined;
|
|
553
|
+
}
|
|
554
|
+
const format = typeof candidate.format === "string" &&
|
|
555
|
+
VALID_AUDIO_FORMATS.includes(candidate.format)
|
|
556
|
+
? candidate.format
|
|
557
|
+
: (options.format ?? "mp3");
|
|
558
|
+
return {
|
|
559
|
+
data,
|
|
560
|
+
format,
|
|
561
|
+
voice: typeof candidate.voice === "string" ? candidate.voice : undefined,
|
|
562
|
+
sampleRate: typeof candidate.sampleRate === "number"
|
|
563
|
+
? candidate.sampleRate
|
|
564
|
+
: undefined,
|
|
565
|
+
estimatedDuration: typeof candidate.estimatedDuration === "number"
|
|
566
|
+
? candidate.estimatedDuration
|
|
567
|
+
: undefined,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Ask a handler for a native stream for one segment, or `undefined` to serve
|
|
572
|
+
* the segment from `synthesize()`.
|
|
573
|
+
*
|
|
574
|
+
* `undefined` is the documented "not incrementally deliverable" signal. A
|
|
575
|
+
* handler that throws instead, or hands back something that is not
|
|
576
|
+
* async-iterable, is buggy — but that is a reason to serve the segment from
|
|
577
|
+
* the buffered path, not to lose it.
|
|
578
|
+
*
|
|
579
|
+
* **Every failure this method anticipates answers `undefined` instead of
|
|
580
|
+
* throwing, and the caller's catch backstops what no code that must
|
|
581
|
+
* describe a hostile thrown value can rule out.** Everything it does is a question ABOUT the
|
|
582
|
+
* handler, asked before any of the segment's work has begun, and every one
|
|
583
|
+
* of those questions can run consumer code: reading `synthesizeStream` or
|
|
584
|
+
* `isConfigured` can hit an accessor or a `Proxy` trap, calling
|
|
585
|
+
* `isConfigured()` and calling the member run handler code outright, and
|
|
586
|
+
* reading `Symbol.asyncIterator` off whatever comes back can hit a getter or
|
|
587
|
+
* a trap of its own. A throw from ANY of them means the same thing — the
|
|
588
|
+
* capability could not be established — so they all sit inside the single
|
|
589
|
+
* guarded region below and every failure answers `undefined`. The segment
|
|
590
|
+
* then goes to `synthesize()`, which re-runs the identical preflight inside
|
|
591
|
+
* its own `tts.synthesize` span and normalizes whatever it raises, exactly
|
|
592
|
+
* as it did before native streaming existed. Reporting a preflight failure
|
|
593
|
+
* from here instead would open a second span for one segment, or drop a
|
|
594
|
+
* segment the buffered path can still serve.
|
|
595
|
+
*
|
|
596
|
+
* Two ordering rules make that equivalence exact:
|
|
597
|
+
*
|
|
598
|
+
* - The member is read FIRST and exactly ONCE per segment. An accessor-
|
|
599
|
+
* defined member runs consumer code on every read, so a second read can
|
|
600
|
+
* answer differently from the one that was tested — and reading it before
|
|
601
|
+
* `isConfigured()` keeps a handler that does not offer the capability at
|
|
602
|
+
* all on precisely the call sequence it had before this method existed.
|
|
603
|
+
* - `isConfigured()` runs only once a callable member has been found, which
|
|
604
|
+
* is the only case where this method is about to invoke the handler.
|
|
605
|
+
*
|
|
606
|
+
* The one measured departure from `origin/release` is a call count, not an
|
|
607
|
+
* outcome: a segment that starts down the native path and falls back calls
|
|
608
|
+
* the handler's `isConfigured()` twice — here, and again inside
|
|
609
|
+
* `synthesize()`. That is inherent to attempting native delivery at all and
|
|
610
|
+
* predates this method's current shape; `isConfigured()` is specified as a
|
|
611
|
+
* configuration predicate, and for any implementation that behaves as one —
|
|
612
|
+
* a pure predicate — every observable of such a segment (chunks, spans,
|
|
613
|
+
* error code and `retriable`) is identical either way. A handler whose
|
|
614
|
+
* answer CHANGES between the two calls changes the outcome with it: the
|
|
615
|
+
* second answer, taken on the path that actually synthesizes, decides.
|
|
616
|
+
*/
|
|
617
|
+
static resolveNativeStream(handler, provider, segment, options) {
|
|
618
|
+
try {
|
|
619
|
+
const member = handler.synthesizeStream;
|
|
620
|
+
if (!this.isCallableMember(member)) {
|
|
621
|
+
// The member is declared `unknown`, so a handler may carry a legacy
|
|
622
|
+
// value of any shape under this name. Only a callable one can be the
|
|
623
|
+
// native capability; anything else means this handler simply does not
|
|
624
|
+
// offer it, and the segment belongs on the buffered path.
|
|
625
|
+
return undefined;
|
|
626
|
+
}
|
|
627
|
+
if (!handler.isConfigured()) {
|
|
628
|
+
// Deliberately not reported here. `synthesize()` runs the same check
|
|
629
|
+
// inside its own span and raises the same
|
|
630
|
+
// `TTS_PROVIDER_NOT_CONFIGURED` error, which is what this segment got
|
|
631
|
+
// before native streaming existed.
|
|
632
|
+
return undefined;
|
|
633
|
+
}
|
|
634
|
+
const candidate = member.call(handler, segment, options);
|
|
635
|
+
if (candidate === undefined) {
|
|
636
|
+
return undefined;
|
|
342
637
|
}
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
textLength: text.trim().length,
|
|
355
|
-
options,
|
|
356
|
-
},
|
|
357
|
-
originalError: err instanceof Error ? err : undefined,
|
|
358
|
-
});
|
|
638
|
+
// The well-known-symbol READ lives inside this try alongside the call:
|
|
639
|
+
// it can execute user code (a throwing getter, a Proxy trap) exactly as
|
|
640
|
+
// the call can, and losing every segment to a bad read is precisely
|
|
641
|
+
// what this fallback exists to prevent.
|
|
642
|
+
if (this.isNativeStream(candidate)) {
|
|
643
|
+
return candidate;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
catch (nativeSetupError) {
|
|
647
|
+
logger.warn(`[TTSProcessor] Provider "${provider}" threw while establishing synthesizeStream(); using buffered synthesis for this segment: ${safeErrorMessage(nativeSetupError)}`);
|
|
648
|
+
return undefined;
|
|
359
649
|
}
|
|
650
|
+
logger.warn(`[TTSProcessor] Provider "${provider}" returned a non-iterable from synthesizeStream(); using buffered synthesis for this segment.`);
|
|
651
|
+
return undefined;
|
|
360
652
|
}
|
|
361
653
|
/**
|
|
362
654
|
* Incrementally synthesize sentence-buffered text chunks.
|
|
363
655
|
*
|
|
364
656
|
* Text is flushed at a sentence boundary after `streamingBufferSize`
|
|
365
657
|
* 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
658
|
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
659
|
+
* A segment is served by the handler's `synthesizeStream()` when it offers
|
|
660
|
+
* one and returns a stream, and by `synthesize()` otherwise — including
|
|
661
|
+
* when the native stream produces no deliverable audio at all, and including
|
|
662
|
+
* every way capability discovery itself can fail. The preflight reads and
|
|
663
|
+
* calls that decide whether a native stream exists all sit inside one
|
|
664
|
+
* guarded region in `resolveNativeStream`, with the call site's own catch
|
|
665
|
+
* backstopping it, so a handler that misbehaves while being ASKED lands on
|
|
666
|
+
* the buffered path rather than costing the segment. That is what makes the
|
|
667
|
+
* next sentence true of every segment rather than only of the ones that got
|
|
668
|
+
* that far.
|
|
669
|
+
*
|
|
670
|
+
* Either way the segment keeps the same handler registry, validation, error
|
|
671
|
+
* normalization and `tts.synthesize` telemetry seam — exactly one span per
|
|
672
|
+
* segment, opened by whichever path served it — cancellation included: a
|
|
673
|
+
* segment whose stream is still in flight when the consumer stops records
|
|
674
|
+
* its span from the unwind path rather than dropping it. Failures that
|
|
675
|
+
* originate in the native segment's own work, once a stream has been
|
|
676
|
+
* established, are a different case and keep the shaped failed-segment
|
|
677
|
+
* semantics every synthesis failure has.
|
|
678
|
+
*
|
|
679
|
+
* Provider-reported indexes, cumulative sizes and finality are discarded and
|
|
680
|
+
* recomputed globally. A native fragment is dropped unless it carries a
|
|
681
|
+
* non-empty binary payload, so no native read reaches the consumer as an
|
|
682
|
+
* empty chunk; the buffered path is unfiltered and forwards whatever
|
|
683
|
+
* `synthesize()` returns, so a handler that produces a zero-byte buffer
|
|
684
|
+
* still yields an empty chunk and a repeated `cumulativeSize`. The most
|
|
685
|
+
* recent successful audio chunk is held until another succeeds or the input
|
|
686
|
+
* ends, so exactly one real audio chunk carries `isFinal: true` without
|
|
687
|
+
* emitting a separate empty terminator chunk.
|
|
688
|
+
*
|
|
689
|
+
* Segment production and per-segment synthesis are deliberately inline
|
|
690
|
+
* rather than nested async generators: each additional generator layer costs
|
|
691
|
+
* every chunk several microtask turns, which is directly observable at
|
|
692
|
+
* `NeuroLink.stream()` as audio interleaving one text chunk later than it
|
|
693
|
+
* does without native streaming.
|
|
372
694
|
*/
|
|
373
|
-
static
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
695
|
+
static synthesizeStream(textChunks, provider, options, shouldStop) {
|
|
696
|
+
const processor = this;
|
|
697
|
+
let cancelled = false;
|
|
698
|
+
let activeNativeStream;
|
|
699
|
+
let activeNativeIterator;
|
|
700
|
+
const stopped = () => cancelled || shouldStop?.() === true;
|
|
701
|
+
const stream = (async function* () {
|
|
702
|
+
const handler = processor.getHandler(provider);
|
|
703
|
+
const maxTextLength = Math.max(1, handler?.maxTextLength ?? processor.DEFAULT_MAX_TEXT_LENGTH);
|
|
704
|
+
const requestedBoundary = options.streamingBufferSize ?? DEFAULT_STREAMING_BUFFER_SIZE;
|
|
705
|
+
const flushBoundary = Math.min(Math.max(1, Math.trunc(requestedBoundary)), maxTextLength);
|
|
706
|
+
let buffer = "";
|
|
707
|
+
let chunkIndex = 0;
|
|
708
|
+
let cumulativeSize = 0;
|
|
709
|
+
let cumulativeDuration = 0;
|
|
710
|
+
let pendingChunk;
|
|
711
|
+
let segmentNumber = 0;
|
|
712
|
+
let firstFailure;
|
|
713
|
+
const failedSegments = [];
|
|
714
|
+
let inputComplete = false;
|
|
715
|
+
const textIterator = textChunks[Symbol.asyncIterator]();
|
|
716
|
+
let textExhausted = false;
|
|
717
|
+
/**
|
|
718
|
+
* The next flushable segment, or `undefined` when more text is needed
|
|
719
|
+
* (or, once the input is complete, when the buffer is drained).
|
|
720
|
+
*/
|
|
721
|
+
const takeSegment = () => {
|
|
722
|
+
for (;;) {
|
|
723
|
+
const buffered = takeBufferedSegment(buffer, flushBoundary, maxTextLength, inputComplete);
|
|
724
|
+
if (!buffered) {
|
|
725
|
+
return undefined;
|
|
726
|
+
}
|
|
727
|
+
buffer = buffered.remainder;
|
|
728
|
+
if (buffered.segment) {
|
|
729
|
+
return buffered.segment;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
/**
|
|
734
|
+
* Read one segment's audio from a handler's native stream.
|
|
735
|
+
*
|
|
736
|
+
* Only NATIVE segments pay for this extra generator layer. The buffered
|
|
737
|
+
* path stays inline in the loop below, which is what keeps its chunks
|
|
738
|
+
* interleaving where they did before native streaming existed.
|
|
739
|
+
*
|
|
740
|
+
* Sets `outcome.fellBackToBuffered` when the stream completed without a
|
|
741
|
+
* single deliverable fragment, which is the caller's signal to serve the
|
|
742
|
+
* segment from `synthesize()` after all.
|
|
743
|
+
*/
|
|
744
|
+
const readNativeSegment = async function* (nativeStream, segment, outcome) {
|
|
745
|
+
const span = processor.startSynthesisSpan(provider, options);
|
|
746
|
+
let spanSettled = false;
|
|
747
|
+
const settleSpan = (status, message) => {
|
|
748
|
+
if (spanSettled) {
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
spanSettled = true;
|
|
752
|
+
processor.finishSynthesisSpan(span, status, message);
|
|
401
753
|
};
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
754
|
+
let nativeComplete = false;
|
|
755
|
+
let emitted = 0;
|
|
756
|
+
try {
|
|
757
|
+
activeNativeStream = nativeStream;
|
|
758
|
+
activeNativeIterator = nativeStream[Symbol.asyncIterator]();
|
|
759
|
+
while (!stopped()) {
|
|
760
|
+
const result = await activeNativeIterator.next();
|
|
761
|
+
if (result.done) {
|
|
762
|
+
nativeComplete = true;
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
const fragment = processor.normalizeNativeChunk(result.value, options);
|
|
766
|
+
// Not audio — an empty read, or a fragment carrying no binary
|
|
767
|
+
// payload at all. Emitting it would put an empty chunk in front of
|
|
768
|
+
// the consumer and repeat `cumulativeSize`.
|
|
769
|
+
if (!fragment) {
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
cumulativeSize += fragment.data.length;
|
|
773
|
+
cumulativeDuration += fragment.estimatedDuration ?? 0;
|
|
774
|
+
emitted += 1;
|
|
775
|
+
yield {
|
|
776
|
+
data: fragment.data,
|
|
777
|
+
format: fragment.format,
|
|
778
|
+
index: chunkIndex++,
|
|
779
|
+
isFinal: false,
|
|
780
|
+
cumulativeSize,
|
|
781
|
+
estimatedDuration: cumulativeDuration || undefined,
|
|
782
|
+
voice: fragment.voice ?? options.voice,
|
|
783
|
+
sampleRate: fragment.sampleRate,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
if (emitted === 0 && !stopped()) {
|
|
787
|
+
// The native stream ran to completion without producing a single
|
|
788
|
+
// deliverable byte. Treating that as a successful segment yields
|
|
789
|
+
// no chunk at all — no audio, no final chunk, and no error to
|
|
790
|
+
// explain it. Serve the segment from the buffered path instead,
|
|
791
|
+
// which is what this method did before native streaming existed.
|
|
792
|
+
// The span is deliberately left unrecorded: `synthesize()` opens
|
|
793
|
+
// its own, and one segment must not report two.
|
|
794
|
+
outcome.fellBackToBuffered = true;
|
|
795
|
+
logger.warn(`[TTSProcessor] Provider "${provider}" produced no audio from synthesizeStream(); falling back to buffered synthesis for this segment.`);
|
|
796
|
+
}
|
|
406
797
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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;
|
|
798
|
+
catch (nativeError) {
|
|
799
|
+
settleSpan(SpanStatus.ERROR, safeErrorMessage(nativeError));
|
|
800
|
+
// Shape the transport failure exactly as the buffered path does, so
|
|
801
|
+
// `ttsMetadata.error` keeps its code, message prefix and `retriable`
|
|
802
|
+
// flag whichever path served the segment.
|
|
803
|
+
throw processor.toSynthesisError(nativeError, provider, segment, options);
|
|
421
804
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
805
|
+
finally {
|
|
806
|
+
const iterator = activeNativeIterator;
|
|
807
|
+
activeNativeIterator = undefined;
|
|
808
|
+
activeNativeStream = undefined;
|
|
809
|
+
if (!nativeComplete && iterator) {
|
|
810
|
+
if (stopped()) {
|
|
811
|
+
cancelStream(nativeStream);
|
|
812
|
+
}
|
|
813
|
+
releaseIterator(iterator);
|
|
814
|
+
}
|
|
815
|
+
if (!outcome.fellBackToBuffered) {
|
|
816
|
+
// Also reached when the consumer stops mid-segment: that resumes
|
|
817
|
+
// this generator with a `return` completion at the `yield` above,
|
|
818
|
+
// so the normal-completion path never runs. The provider still did
|
|
819
|
+
// real work and delivered bytes, and the buffered path records a
|
|
820
|
+
// span for the same user action — closing it here is what keeps
|
|
821
|
+
// the two at parity.
|
|
822
|
+
settleSpan(SpanStatus.OK);
|
|
823
|
+
}
|
|
425
824
|
}
|
|
426
|
-
|
|
427
|
-
|
|
825
|
+
};
|
|
826
|
+
/**
|
|
827
|
+
* Record one segment's synthesis failure.
|
|
828
|
+
*
|
|
829
|
+
* Gated on our OWN teardown flag, never on `stopped()`: cancelling this
|
|
830
|
+
* stream makes an in-flight transport reject, and that rejection is not
|
|
831
|
+
* a segment failure. A caller-driven `shouldStop()` is a different thing
|
|
832
|
+
* entirely, and suppressing here for it erased genuine failures that
|
|
833
|
+
* were reported before native streaming existed.
|
|
834
|
+
*/
|
|
835
|
+
const recordSegmentFailure = (segmentNo, error) => {
|
|
836
|
+
if (cancelled) {
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (failedSegments.length === 0) {
|
|
840
|
+
firstFailure = error;
|
|
841
|
+
}
|
|
842
|
+
failedSegments.push(segmentNo);
|
|
843
|
+
logger.warn(`[TTSProcessor] Incremental synthesis skipped a buffered segment: ${safeErrorMessage(error)}`);
|
|
844
|
+
};
|
|
845
|
+
try {
|
|
846
|
+
while (!stopped()) {
|
|
847
|
+
const segment = takeSegment();
|
|
848
|
+
if (segment === undefined) {
|
|
849
|
+
if (inputComplete) {
|
|
850
|
+
break;
|
|
851
|
+
}
|
|
852
|
+
const next = await textIterator.next();
|
|
853
|
+
if (next.done) {
|
|
854
|
+
textExhausted = true;
|
|
855
|
+
inputComplete = true;
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
buffer += next.value;
|
|
859
|
+
}
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
const currentSegment = ++segmentNumber;
|
|
863
|
+
// NOTHING BELOW MAY `yield` INSIDE A `try` THAT HAS A `catch`.
|
|
864
|
+
// A consumer can resume this generator with `AsyncGenerator.throw()`
|
|
865
|
+
// while it is suspended at a `yield`; the injected error surfaces AT
|
|
866
|
+
// the yield expression. If a segment `catch` were in scope there, it
|
|
867
|
+
// would swallow the consumer's own error and report it as a provider
|
|
868
|
+
// segment failure — re-delivering the parked chunk, dropping a
|
|
869
|
+
// segment, and finally raising a fabricated
|
|
870
|
+
// `IncrementalTTSSynthesisError` attributed to the provider. Before
|
|
871
|
+
// this loop was flattened the yields sat outside every try and the
|
|
872
|
+
// error propagated, which is the behaviour callers still get.
|
|
873
|
+
// Segment work is wrapped; the yields are not.
|
|
874
|
+
// Capability discovery is delegated whole. There is deliberately no
|
|
875
|
+
// `handler.synthesizeStream` truthiness gate here: that gate was a
|
|
876
|
+
// SECOND read of an untrusted member on every segment, so an
|
|
877
|
+
// accessor-defined member ran consumer code twice and the value that
|
|
878
|
+
// was tested need not be the value that was called.
|
|
879
|
+
// `resolveNativeStream` performs the one read, inside its guard.
|
|
880
|
+
let nativeStream;
|
|
881
|
+
try {
|
|
882
|
+
nativeStream = handler
|
|
883
|
+
? processor.resolveNativeStream(handler, provider, segment, options)
|
|
884
|
+
: undefined;
|
|
885
|
+
}
|
|
886
|
+
catch (discoveryError) {
|
|
887
|
+
// Rarely reached — `resolveNativeStream` answers `undefined` for
|
|
888
|
+
// every failure it anticipates — but a sufficiently hostile
|
|
889
|
+
// thrown value can escape any code that must describe it, and
|
|
890
|
+
// the disposition here is what matters when one does.
|
|
891
|
+
// Discovery asks about the handler; it never does the segment's
|
|
892
|
+
// work, so a failure here means "no native capability", not "no
|
|
893
|
+
// audio". Losing the segment to it is the defect this branch has
|
|
894
|
+
// now been refuted for twice.
|
|
895
|
+
logger.warn(`[TTSProcessor] Provider "${provider}" threw during native capability discovery; using buffered synthesis for this segment: ${safeErrorMessage(discoveryError)}`);
|
|
896
|
+
nativeStream = undefined;
|
|
897
|
+
}
|
|
898
|
+
if (nativeStream) {
|
|
899
|
+
const outcome = { fellBackToBuffered: false };
|
|
900
|
+
const native = readNativeSegment(nativeStream, segment, outcome);
|
|
901
|
+
let nativeDone = false;
|
|
902
|
+
let nativeFailed = false;
|
|
903
|
+
try {
|
|
904
|
+
for (;;) {
|
|
905
|
+
let step;
|
|
906
|
+
try {
|
|
907
|
+
step = await native.next();
|
|
908
|
+
}
|
|
909
|
+
catch (error) {
|
|
910
|
+
// A generator that threw is already complete, so there is
|
|
911
|
+
// nothing left to release.
|
|
912
|
+
nativeDone = true;
|
|
913
|
+
nativeFailed = true;
|
|
914
|
+
recordSegmentFailure(currentSegment, error);
|
|
915
|
+
break;
|
|
916
|
+
}
|
|
917
|
+
if (step.done) {
|
|
918
|
+
nativeDone = true;
|
|
919
|
+
break;
|
|
920
|
+
}
|
|
921
|
+
if (pendingChunk) {
|
|
922
|
+
yield pendingChunk;
|
|
923
|
+
}
|
|
924
|
+
pendingChunk = step.value;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
finally {
|
|
928
|
+
// Reached on a consumer `.throw()` or `.return()` at the yield
|
|
929
|
+
// above as well as on a normal break. `for await` closed the
|
|
930
|
+
// inner generator for us; driving it by hand means closing it
|
|
931
|
+
// here, so `readNativeSegment`'s own `finally` still settles the
|
|
932
|
+
// span and releases the transport iterator.
|
|
933
|
+
if (!nativeDone) {
|
|
934
|
+
await native.return(undefined);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (nativeFailed || !outcome.fellBackToBuffered) {
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
let result;
|
|
942
|
+
try {
|
|
943
|
+
result = await processor.synthesize(segment, provider, options);
|
|
944
|
+
}
|
|
945
|
+
catch (error) {
|
|
946
|
+
recordSegmentFailure(currentSegment, error);
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
cumulativeSize += result.size;
|
|
950
|
+
cumulativeDuration += result.duration ?? 0;
|
|
951
|
+
const chunk = {
|
|
952
|
+
data: result.buffer,
|
|
953
|
+
format: result.format,
|
|
954
|
+
index: chunkIndex++,
|
|
955
|
+
isFinal: false,
|
|
956
|
+
cumulativeSize,
|
|
957
|
+
estimatedDuration: cumulativeDuration || undefined,
|
|
958
|
+
voice: result.voice,
|
|
959
|
+
sampleRate: result.sampleRate,
|
|
960
|
+
};
|
|
428
961
|
if (pendingChunk) {
|
|
429
962
|
yield pendingChunk;
|
|
430
963
|
}
|
|
431
964
|
pendingChunk = chunk;
|
|
432
965
|
}
|
|
433
966
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
967
|
+
finally {
|
|
968
|
+
// Mirror what `for await` did over `textChunks` before this loop was
|
|
969
|
+
// driven by hand: close the source on any early exit, and leave an
|
|
970
|
+
// already-exhausted iterator alone.
|
|
971
|
+
if (!textExhausted) {
|
|
972
|
+
const releaseText = textIterator.return;
|
|
973
|
+
if (typeof releaseText === "function") {
|
|
974
|
+
await releaseText.call(textIterator, undefined);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
439
977
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
978
|
+
// Preserve the existing iterator-release handshake: a consumer break may
|
|
979
|
+
// already have one normalized chunk parked behind the chunk it observed.
|
|
980
|
+
// Let that in-flight `next()` settle so the queued `.return()` can enter
|
|
981
|
+
// the generator and run wrapper `finally` blocks; the abandoned consumer
|
|
982
|
+
// never receives this value.
|
|
983
|
+
if (pendingChunk) {
|
|
984
|
+
yield { ...pendingChunk, isFinal: true };
|
|
443
985
|
}
|
|
444
|
-
|
|
445
|
-
if (
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
986
|
+
// Unconditional, as before native streaming: a segment that already
|
|
987
|
+
// failed is reported even if `shouldStop()` has since flipped true. A
|
|
988
|
+
// consumer that broke never reaches this line (its queued `.return()`
|
|
989
|
+
// unwinds the generator at the yield above), so gating on `stopped()`
|
|
990
|
+
// only ever suppressed a real failure for a caller driving
|
|
991
|
+
// `shouldStop` directly.
|
|
992
|
+
if (failedSegments.length > 0) {
|
|
993
|
+
throw new IncrementalTTSSynthesisError(firstFailure, failedSegments);
|
|
450
994
|
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
995
|
+
})();
|
|
996
|
+
return attachStreamCancel(stream, () => {
|
|
997
|
+
cancelled = true;
|
|
998
|
+
cancelStream(activeNativeStream);
|
|
999
|
+
if (activeNativeIterator) {
|
|
1000
|
+
releaseIterator(activeNativeIterator);
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
458
1003
|
}
|
|
459
1004
|
}
|