@juspay/neurolink 12.6.1 → 12.7.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.
@@ -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, err instanceof Error ? err.message : String(err));
441
+ const endedSpan = SpanSerializer.endSpan(span, SpanStatus.ERROR, safeErrorMessage(err));
337
442
  getMetricsAggregator().recordSpan(endedSpan);
338
443
  // 9. Comprehensive error handling
339
- // Re-throw TTSError as-is
340
- if (err instanceof TTSError) {
341
- throw err;
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
- // Wrap other errors in TTSError
344
- const errorMessage = err instanceof Error ? err.message : String(err || "Unknown error");
345
- logger.error(`[TTSProcessor] Synthesis failed for provider "${provider}": ${errorMessage}`);
346
- throw new TTSError({
347
- code: TTS_ERROR_CODES.SYNTHESIS_FAILED,
348
- message: `TTS synthesis failed for provider "${provider}": ${errorMessage}`,
349
- category: ErrorCategory.EXECUTION,
350
- severity: ErrorSeverity.HIGH,
351
- retriable: true,
352
- context: {
353
- provider,
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
- * 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.
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 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,
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
- catch (error) {
404
- if (failedSegments.length === 0) {
405
- firstFailure = error;
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
- 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;
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
- buffer = buffered.remainder;
423
- if (!buffered.segment) {
424
- continue;
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
- const chunk = await synthesizeSegment(buffered.segment);
427
- if (chunk) {
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
- while (!shouldStop?.()) {
436
- const buffered = takeBufferedSegment(buffer, flushBoundary, maxTextLength, true);
437
- if (!buffered) {
438
- break;
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
- buffer = buffered.remainder;
441
- if (!buffered.segment) {
442
- continue;
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
- const chunk = await synthesizeSegment(buffered.segment);
445
- if (chunk) {
446
- if (pendingChunk) {
447
- yield pendingChunk;
448
- }
449
- pendingChunk = chunk;
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
- if (pendingChunk) {
453
- yield { ...pendingChunk, isFinal: true };
454
- }
455
- if (failedSegments.length > 0) {
456
- throw new IncrementalTTSSynthesisError(firstFailure, failedSegments);
457
- }
995
+ })();
996
+ return attachStreamCancel(stream, () => {
997
+ cancelled = true;
998
+ cancelStream(activeNativeStream);
999
+ if (activeNativeIterator) {
1000
+ releaseIterator(activeNativeIterator);
1001
+ }
1002
+ });
458
1003
  }
459
1004
  }