@ssml-builder-js/azure-tts-client 2.15.0 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -37,6 +37,7 @@ var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "
37
37
  // src/index.ts
38
38
  var index_exports = {};
39
39
  __export(index_exports, {
40
+ AudioFormatMismatchError: () => AudioFormatMismatchError,
40
41
  AzureTtsClient: () => AzureTtsClient,
41
42
  AzureTtsError: () => AzureTtsError,
42
43
  AzureTtsSdkError: () => AzureTtsSdkError,
@@ -48,6 +49,7 @@ __export(index_exports, {
48
49
  UnsupportedMergeFormatError: () => UnsupportedMergeFormatError,
49
50
  canMergeAudioFormat: () => canMergeAudioFormat,
50
51
  fetchAzureVoiceCatalog: () => fetchAzureVoiceCatalog,
52
+ inspectAudioSpecification: () => inspectAudioSpecification,
51
53
  mergeAudioBuffers: () => mergeAudioBuffers,
52
54
  mergeSynthesisResults: () => mergeSynthesisResults,
53
55
  resolveMergeAudioFormat: () => resolveMergeAudioFormat,
@@ -102,6 +104,14 @@ var MergeError = class extends Error {
102
104
  this.cause = cause;
103
105
  }
104
106
  };
107
+ var AudioFormatMismatchError = class extends Error {
108
+ constructor(message, inputSpecs = []) {
109
+ super(message);
110
+ this.kind = "audio-format-mismatch";
111
+ this.name = "AudioFormatMismatchError";
112
+ this.inputSpecs = inputSpecs;
113
+ }
114
+ };
105
115
  var UnsupportedMergeFormatError = class extends Error {
106
116
  constructor(format) {
107
117
  super(`Audio format "${format}" cannot be safely concatenated; container re-multiplexing is required.`);
@@ -111,7 +121,7 @@ var UnsupportedMergeFormatError = class extends Error {
111
121
  }
112
122
  };
113
123
  function toSynthesisError(error) {
114
- if (error instanceof AzureTtsError || error instanceof MergeError || error instanceof UnsupportedMergeFormatError || error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError)
124
+ if (error instanceof AzureTtsError || error instanceof MergeError || error instanceof AudioFormatMismatchError || error instanceof UnsupportedMergeFormatError || error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError)
115
125
  return error;
116
126
  const message = error instanceof Error ? error.message : String(error);
117
127
  if (/cancel|abort/i.test(message)) return new SynthesisCancelledError(message);
@@ -241,6 +251,105 @@ function parseWav(buffer) {
241
251
  }
242
252
  return { chunks, data, format };
243
253
  }
254
+ function formatNumber(format, pattern, fallback) {
255
+ const match = pattern.exec(format);
256
+ return match?.[1] ? Number(match[1]) : fallback;
257
+ }
258
+ function formatChannels(format, fallback) {
259
+ if (/stereo|2ch|dual/i.test(format)) return 2;
260
+ if (/mono|1ch/i.test(format)) return 1;
261
+ return fallback;
262
+ }
263
+ function formatAudioSpecification(format) {
264
+ const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
265
+ const channels = formatChannels(format, 0);
266
+ const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
267
+ const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1e3 : void 0;
268
+ const codec = /mp3|mpeg/i.test(format) ? "mp3" : /opus/i.test(format) ? "opus" : /silk/i.test(format) ? "silk" : /pcm|mulaw|alaw|siren/i.test(format) ? "pcm" : "unknown";
269
+ return {
270
+ format,
271
+ mimeType: resolveMimeType(format),
272
+ codec,
273
+ sampleRate,
274
+ channels,
275
+ ...bitrate ? { bitrate } : {},
276
+ isCompressed: codec === "mp3" || codec === "opus" || codec === "silk"
277
+ };
278
+ }
279
+ function parseMp3Specification(buffer, format) {
280
+ const bytes = stripMp3Tags(buffer);
281
+ const bitrates = [
282
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
283
+ [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0],
284
+ [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0]
285
+ ];
286
+ const sampleRates = [
287
+ [44100, 48e3, 32e3],
288
+ [22050, 24e3, 16e3],
289
+ [11025, 12e3, 8e3]
290
+ ];
291
+ for (let index = 0; index + 4 <= bytes.length; index += 1) {
292
+ if (bytes[index] !== 255 || (bytes[index + 1] ?? 0) < 224) continue;
293
+ const header = bytes[index + 1] ?? 0;
294
+ const versionBits = header >> 3 & 3;
295
+ const layer = header >> 1 & 3;
296
+ const bitrateIndex = (bytes[index + 2] ?? 0) >> 4;
297
+ const sampleIndex = (bytes[index + 2] ?? 0) >> 2 & 3;
298
+ if (versionBits === 1 || layer !== 1 || bitrateIndex === 0 || bitrateIndex === 15 || sampleIndex === 3) continue;
299
+ const versionIndex = versionBits === 3 ? 0 : versionBits === 2 ? 1 : 2;
300
+ const bitrateTable = versionBits === 3 ? bitrates[1] : bitrates[2];
301
+ const sampleRate = sampleRates[versionIndex]?.[sampleIndex] ?? 0;
302
+ const bitrateKbps = bitrateTable?.[bitrateIndex] ?? 0;
303
+ if (!sampleRate || !bitrateKbps) continue;
304
+ return {
305
+ format,
306
+ mimeType: "audio/mpeg",
307
+ codec: "mp3",
308
+ sampleRate,
309
+ channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
310
+ bitrate: bitrateKbps * 1e3,
311
+ isCompressed: true
312
+ };
313
+ }
314
+ return void 0;
315
+ }
316
+ function inspectAudioSpecification(buffer, format) {
317
+ if (isWavFormat(format) || ascii(new Uint8Array(buffer), 0, "RIFF")) {
318
+ const parsed = parseWav(buffer);
319
+ if (parsed.format.byteLength < 16) throw new Error("Invalid WAV fmt chunk.");
320
+ const view = new DataView(parsed.format.buffer, parsed.format.byteOffset, parsed.format.byteLength);
321
+ const sampleRate = view.getUint32(4, true);
322
+ const channels = view.getUint16(2, true);
323
+ const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
324
+ const formatCode = view.getUint16(0, true);
325
+ return {
326
+ format,
327
+ mimeType: "audio/wav",
328
+ codec: formatCode === 1 ? "pcm" : "unknown",
329
+ sampleRate,
330
+ channels,
331
+ ...sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {},
332
+ isCompressed: formatCode !== 1
333
+ };
334
+ }
335
+ if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
336
+ return formatAudioSpecification(format);
337
+ }
338
+ function validateAudioSpecifications(specs) {
339
+ const first = specs[0];
340
+ if (!first) return;
341
+ const mismatch = specs.find(
342
+ (spec) => spec.sampleRate !== first.sampleRate || spec.channels !== first.channels || first.bitrate !== void 0 && spec.bitrate !== void 0 && spec.bitrate !== first.bitrate
343
+ );
344
+ if (mismatch)
345
+ throw new AudioFormatMismatchError(
346
+ `Audio chunks have incompatible specifications: ${first.sampleRate}Hz/${first.channels}ch versus ${mismatch.sampleRate}Hz/${mismatch.channels}ch.`,
347
+ specs
348
+ );
349
+ }
350
+ function isAudioFormatMismatch(error) {
351
+ return error instanceof AudioFormatMismatchError || error !== null && typeof error === "object" && "kind" in error && error.kind === "audio-format-mismatch";
352
+ }
244
353
  function writeUint32(target, offset, value) {
245
354
  new DataView(target.buffer).setUint32(offset, value, true);
246
355
  }
@@ -326,6 +435,7 @@ function mergeAudioBuffers(buffers, options) {
326
435
  const format = typeof options === "string" ? options : options?.format;
327
436
  if (!format) throw new UnsupportedMergeFormatError("");
328
437
  try {
438
+ validateAudioSpecifications(buffers.map((buffer) => inspectAudioSpecification(buffer, format)));
329
439
  if (isWavFormat(format)) return mergeWavBuffers(buffers);
330
440
  if (isMp3Format(format)) {
331
441
  const parts = buffers.map(stripMp3Tags);
@@ -348,7 +458,8 @@ function mergeAudioBuffers(buffers, options) {
348
458
  }
349
459
  throw new UnsupportedMergeFormatError(format);
350
460
  } catch (error) {
351
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
461
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
462
+ throw error;
352
463
  throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
353
464
  }
354
465
  }
@@ -426,17 +537,26 @@ async function synthesizeSsml(ssml, config) {
426
537
  return {
427
538
  originalTextRange: { ...marker.originalTextRange },
428
539
  sourceNodePath: [...marker.sourceNodePath],
429
- textRange: { ...marker.originalTextRange }
540
+ textRange: { ...marker.originalTextRange },
541
+ mappingStatus: "exact"
430
542
  };
431
543
  }
432
- if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) return {};
544
+ if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) {
545
+ const unmapped = { mappingStatus: "unmapped" };
546
+ Object.defineProperty(unmapped, "mappingStatus", { value: "unmapped", enumerable: false });
547
+ return unmapped;
548
+ }
433
549
  const value = text ?? "";
434
550
  let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? offsetHint : -1;
435
- if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value)
551
+ let mappingStatus = "exact";
552
+ if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value) {
436
553
  localStart = -1;
554
+ mappingStatus = "fallback";
555
+ }
437
556
  if (localStart < 0 || localStart > sourceText.length) {
438
557
  localStart = value ? sourceText.indexOf(value, sourceEventCursor) : sourceEventCursor;
439
558
  if (localStart < 0) localStart = value ? sourceText.indexOf(value) : sourceEventCursor;
559
+ mappingStatus = "fallback";
440
560
  }
441
561
  localStart = Math.max(0, localStart);
442
562
  const localEnd = Math.min(sourceText.length, localStart + value.length);
@@ -447,7 +567,8 @@ async function synthesizeSsml(ssml, config) {
447
567
  return {
448
568
  originalTextRange: { ...fallbackRange },
449
569
  textRange: { ...fallbackRange },
450
- ...segment ? { sourceNodePath: [...segment.sourceNodePath] } : config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {}
570
+ ...segment ? { sourceNodePath: [...segment.sourceNodePath] } : config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
571
+ mappingStatus: segment || config.sourceTextRange || config.sourceNodePath ? mappingStatus : "unmapped"
451
572
  };
452
573
  };
453
574
  synthesizer.wordBoundary = (_sender, event) => {
@@ -496,20 +617,27 @@ async function synthesizeSsml(ssml, config) {
496
617
  );
497
618
  const durationMs = result.audioDuration ? ticksToMilliseconds(result.audioDuration) : eventDurationMs;
498
619
  const requestId = result.resultId;
499
- const addSourceMetadata = (event) => ({
500
- ...event,
501
- ...config.sourceTextRange && !("textRange" in event) ? { textRange: { ...config.sourceTextRange } } : {},
502
- ...config.sourceTextRange && !("originalTextRange" in event) ? { originalTextRange: { ...config.sourceTextRange } } : {},
503
- ...config.chunkIndex !== void 0 ? { chunkIndex: config.chunkIndex } : {},
504
- ...config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
505
- ...requestId ? { requestId } : {}
506
- });
620
+ const addSourceMetadata = (event) => {
621
+ const mapped = {
622
+ ...event,
623
+ ...config.sourceTextRange && !("textRange" in event) ? { textRange: { ...config.sourceTextRange } } : {},
624
+ ...config.sourceTextRange && !("originalTextRange" in event) ? { originalTextRange: { ...config.sourceTextRange } } : {},
625
+ ...config.chunkIndex !== void 0 ? { chunkIndex: config.chunkIndex } : {},
626
+ ...config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
627
+ ...requestId ? { requestId } : {}
628
+ };
629
+ if (event.mappingStatus === "unmapped")
630
+ Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
631
+ return mapped;
632
+ };
507
633
  const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
508
634
  const sourceVisemes = visemes.map((viseme) => addSourceMetadata(viseme));
509
635
  const sourceBookmarks = bookmarks.map((bookmark) => addSourceMetadata(bookmark));
510
636
  resolve({
511
637
  audioData: result.audioData,
512
638
  durationMs,
639
+ audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
640
+ mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
513
641
  ...config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {},
514
642
  ...requestId ? { requestId } : {},
515
643
  ...sourceBoundaries.length > 0 ? { boundaries: sourceBoundaries, wordBoundary: sourceBoundaries, wordBoundaries: sourceBoundaries } : {},
@@ -534,8 +662,66 @@ async function synthesizeSsml(ssml, config) {
534
662
  }
535
663
  });
536
664
  }
665
+ function isRetryableSynthesisError(error) {
666
+ if (error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError) return false;
667
+ if (error instanceof AzureTtsError && error.status !== 0)
668
+ return error.status === 429 || error.status >= 500 && error.status < 600;
669
+ const message = error instanceof Error ? error.message : String(error);
670
+ if (/\b4\d{2}\b/.test(message)) return false;
671
+ const status = error && typeof error === "object" && "status" in error ? error.status : void 0;
672
+ if (typeof status === "number") return status === 429 || status >= 500 && status < 600;
673
+ return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
674
+ }
675
+ function retryDelay(options, retryAttempt) {
676
+ const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
677
+ return Math.floor(Math.random() * (base + 1));
678
+ }
679
+ function resolveConcurrency(value, total) {
680
+ if (value === void 0) return 1;
681
+ if (value === Infinity) return Math.max(1, total);
682
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
683
+ }
684
+ async function waitForRetry(delayMs, signal) {
685
+ if (signal?.aborted) throw new SynthesisCancelledError();
686
+ if (delayMs <= 0) return;
687
+ await new Promise((resolve, reject) => {
688
+ let timer;
689
+ const abort = () => {
690
+ clearTimeout(timer);
691
+ signal?.removeEventListener("abort", abort);
692
+ reject(new SynthesisCancelledError());
693
+ };
694
+ timer = setTimeout(() => {
695
+ signal?.removeEventListener("abort", abort);
696
+ resolve();
697
+ }, delayMs);
698
+ if (signal) {
699
+ signal.addEventListener("abort", abort, { once: true });
700
+ }
701
+ });
702
+ }
703
+ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
704
+ const options = retryOptions ? {
705
+ maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
706
+ initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
707
+ maxDelayMs: Math.max(0, retryOptions.maxDelayMs)
708
+ } : void 0;
709
+ let attempt = 0;
710
+ while (true) {
711
+ if (config.signal?.aborted) throw new SynthesisCancelledError();
712
+ try {
713
+ return await synthesizeSsml(ssml, config);
714
+ } catch (error) {
715
+ if (!options || attempt >= options.maxRetries || !isRetryableSynthesisError(error)) throw error;
716
+ attempt += 1;
717
+ const delayMs = retryDelay(options, attempt);
718
+ onRetry(attempt, delayMs);
719
+ await waitForRetry(delayMs, config.signal);
720
+ }
721
+ }
722
+ }
537
723
  async function synthesizeSsmlChunks(chunks, config) {
538
- const results = [];
724
+ const results = new Array(chunks.length);
539
725
  const totalChunks = chunks.length;
540
726
  const report = (event) => config.onProgress?.(event);
541
727
  for (const [index, chunk] of chunks.entries()) {
@@ -550,57 +736,85 @@ async function synthesizeSsmlChunks(chunks, config) {
550
736
  durationMs: 0
551
737
  });
552
738
  }
553
- for (const [index, chunk] of chunks.entries()) {
554
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
555
- report({
556
- currentChunk: index,
557
- totalChunks,
558
- percent: totalChunks === 0 ? 100 : Math.round(index / totalChunks * 100),
559
- chunkIndex: index,
560
- originalTextRange: input.originalTextRange,
561
- status: "synthesizing",
562
- durationMs: 0
563
- });
564
- const startedAt = Date.now();
565
- try {
566
- const result = await synthesizeSsml(input.ssml, {
567
- ...config,
568
- ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
569
- ...input.sourceNodePath ?? config.sourceNodePath ? { sourceNodePath: [...input.sourceNodePath ?? config.sourceNodePath ?? []] } : {},
570
- ...input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {},
571
- ...input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {},
572
- chunkIndex: index,
573
- onProgress: void 0
574
- });
575
- results.push(result);
576
- report({
577
- currentChunk: index + 1,
578
- totalChunks,
579
- percent: totalChunks === 0 ? 100 : Math.round((index + 1) / totalChunks * 100),
580
- chunkIndex: index,
581
- originalTextRange: input.originalTextRange,
582
- status: "success",
583
- durationMs: Date.now() - startedAt
584
- });
585
- } catch (error) {
739
+ let completed = 0;
740
+ let nextIndex = 0;
741
+ const concurrency = resolveConcurrency(config.concurrency, chunks.length);
742
+ const worker = async () => {
743
+ while (true) {
744
+ const index = nextIndex++;
745
+ if (index >= chunks.length) return;
746
+ const chunk = chunks[index];
747
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
586
748
  report({
587
- currentChunk: index,
749
+ currentChunk: completed,
588
750
  totalChunks,
589
- percent: totalChunks === 0 ? 100 : Math.round(index / totalChunks * 100),
751
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
590
752
  chunkIndex: index,
591
753
  originalTextRange: input.originalTextRange,
592
- status: "failed",
593
- durationMs: Date.now() - startedAt,
594
- error
754
+ status: "synthesizing",
755
+ durationMs: 0
595
756
  });
596
- throw error;
757
+ const startedAt = Date.now();
758
+ try {
759
+ const result = await synthesizeWithRetry(
760
+ input.ssml,
761
+ {
762
+ ...config,
763
+ ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
764
+ ...input.sourceNodePath ?? config.sourceNodePath ? { sourceNodePath: [...input.sourceNodePath ?? config.sourceNodePath ?? []] } : {},
765
+ ...input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {},
766
+ ...input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {},
767
+ chunkIndex: index,
768
+ onProgress: void 0
769
+ },
770
+ config.retryOptions,
771
+ (retryAttempt, nextRetryDelayMs) => report({
772
+ currentChunk: completed,
773
+ totalChunks,
774
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
775
+ chunkIndex: index,
776
+ originalTextRange: input.originalTextRange,
777
+ status: "synthesizing",
778
+ durationMs: Date.now() - startedAt,
779
+ retryAttempt,
780
+ nextRetryDelayMs,
781
+ isRetrying: true
782
+ })
783
+ );
784
+ results[index] = result;
785
+ completed += 1;
786
+ report({
787
+ currentChunk: completed,
788
+ totalChunks,
789
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
790
+ chunkIndex: index,
791
+ originalTextRange: input.originalTextRange,
792
+ status: "success",
793
+ durationMs: Date.now() - startedAt
794
+ });
795
+ } catch (error) {
796
+ report({
797
+ currentChunk: completed,
798
+ totalChunks,
799
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
800
+ chunkIndex: index,
801
+ originalTextRange: input.originalTextRange,
802
+ status: "failed",
803
+ durationMs: Date.now() - startedAt,
804
+ error
805
+ });
806
+ throw error;
807
+ }
597
808
  }
598
- }
599
- return mergeSynthesisResults(results, {
600
- format: config.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3"
809
+ };
810
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
811
+ const orderedResults = results.filter((result) => result !== void 0);
812
+ return mergeSynthesisResults(orderedResults, {
813
+ format: config.outputFormat ?? DEFAULT_OUTPUT_FORMAT,
814
+ signal: config.signal
601
815
  });
602
816
  }
603
- function createMergedResult(results, audioData, format) {
817
+ function createMergedResult(results, audioData, format, audioSpec, outputMimeType) {
604
818
  const boundaries = [];
605
819
  const visemes = [];
606
820
  const bookmarks = [];
@@ -619,7 +833,8 @@ function createMergedResult(results, audioData, format) {
619
833
  ...boundary.sourceNodePath ? { sourceNodePath: [...boundary.sourceNodePath] } : {},
620
834
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
621
835
  ...textRange ? { textRange: { ...textRange } } : {},
622
- ...requestId ? { requestId } : {}
836
+ ...requestId ? { requestId } : {},
837
+ mappingStatus: boundary.mappingStatus ?? "unmapped"
623
838
  });
624
839
  }
625
840
  for (const viseme of result.visemes ?? []) {
@@ -634,7 +849,8 @@ function createMergedResult(results, audioData, format) {
634
849
  ...viseme.sourceNodePath ? { sourceNodePath: [...viseme.sourceNodePath] } : {},
635
850
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
636
851
  ...textRange ? { textRange: { ...textRange } } : {},
637
- ...requestId ? { requestId } : {}
852
+ ...requestId ? { requestId } : {},
853
+ mappingStatus: viseme.mappingStatus ?? "unmapped"
638
854
  });
639
855
  }
640
856
  for (const bookmark of result.bookmarks ?? []) {
@@ -649,7 +865,8 @@ function createMergedResult(results, audioData, format) {
649
865
  ...bookmark.sourceNodePath ? { sourceNodePath: [...bookmark.sourceNodePath] } : {},
650
866
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
651
867
  ...textRange ? { textRange: { ...textRange } } : {},
652
- ...requestId ? { requestId } : {}
868
+ ...requestId ? { requestId } : {},
869
+ mappingStatus: bookmark.mappingStatus ?? "unmapped"
653
870
  });
654
871
  }
655
872
  durationOffset += Math.max(0, result.durationMs);
@@ -658,6 +875,8 @@ function createMergedResult(results, audioData, format) {
658
875
  audioData,
659
876
  durationMs: durationOffset,
660
877
  mimeType: resolveMimeType(format),
878
+ audioSpec: audioSpec ?? formatAudioSpecification(format),
879
+ ...outputMimeType ? { mimeType: outputMimeType } : {},
661
880
  ...boundaries.length > 0 ? { boundaries, wordBoundary: boundaries, wordBoundaries: boundaries } : {},
662
881
  ...visemes.length > 0 ? { visemes } : {},
663
882
  ...bookmarks.length > 0 ? { bookmarks } : {},
@@ -670,19 +889,47 @@ function mergeSynthesisResults(results, options) {
670
889
  const format = resolvedOptions?.format;
671
890
  if (!format) throw new UnsupportedMergeFormatError("");
672
891
  const buffers = results.map((result) => result.audioData);
892
+ const inputSpecs = results.map((result) => result.audioSpec ?? inspectAudioSpecification(result.audioData, format));
893
+ validateAudioSpecifications(inputSpecs);
894
+ const signal = resolvedOptions.signal ?? new AbortController().signal;
895
+ if (signal.aborted) throw new SynthesisCancelledError();
673
896
  if (resolvedOptions.customMerger) {
674
- return Promise.resolve().then(() => resolvedOptions.customMerger?.(buffers, format)).then((merged) => {
897
+ return Promise.resolve().then(
898
+ () => resolvedOptions.customMerger?.(buffers, {
899
+ format,
900
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
901
+ inputSpecs,
902
+ signal
903
+ })
904
+ ).then((merged) => {
675
905
  if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
676
- return createMergedResult(results, merged, format);
906
+ if (!(merged instanceof ArrayBuffer) || buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
907
+ throw new MergeError("The custom audio merger returned an invalid audio buffer.");
908
+ if (signal.aborted) throw new SynthesisCancelledError();
909
+ return createMergedResult(
910
+ results,
911
+ merged,
912
+ format,
913
+ inspectAudioSpecification(merged, format),
914
+ resolvedOptions.outputMimeType
915
+ );
677
916
  }).catch((error) => {
678
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
917
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
918
+ throw error;
679
919
  throw new MergeError(`Custom audio merger failed for format "${format}".`, error);
680
920
  });
681
921
  }
682
922
  try {
683
- return createMergedResult(results, mergeAudioBuffers(buffers, { format }), format);
923
+ return createMergedResult(
924
+ results,
925
+ mergeAudioBuffers(buffers, { format }),
926
+ format,
927
+ inputSpecs[0],
928
+ resolvedOptions.outputMimeType
929
+ );
684
930
  } catch (error) {
685
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
931
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
932
+ throw error;
686
933
  throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
687
934
  }
688
935
  }
@@ -704,8 +951,73 @@ var ChunkValidationError = class extends Error {
704
951
  function failure(error) {
705
952
  return { ok: false, success: false, status: error.kind, error };
706
953
  }
954
+ function isRetryable(error) {
955
+ if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
956
+ const status = error && typeof error === "object" && "status" in error ? error.status : void 0;
957
+ if (typeof status === "number" && status !== 0) return status === 429 || status >= 500 && status < 600;
958
+ const message = error instanceof Error ? error.message : String(error);
959
+ if (/\b4\d{2}\b/.test(message)) return false;
960
+ return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
961
+ }
962
+ function delayForRetry(options, attempt) {
963
+ const maxDelay = Math.max(0, options.maxDelayMs);
964
+ const base = Math.min(maxDelay, Math.max(0, options.initialDelayMs) * 2 ** Math.max(0, attempt - 1));
965
+ return Math.floor(Math.random() * (base + 1));
966
+ }
967
+ function resolveConcurrency2(value, total) {
968
+ if (value === void 0) return 1;
969
+ if (value === Infinity) return Math.max(1, total);
970
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
971
+ }
972
+ async function retryableSynthesis(synthesize, options, signal, onRetry) {
973
+ const retry = options ? {
974
+ maxRetries: Math.max(0, Math.floor(options.maxRetries)),
975
+ initialDelayMs: options.initialDelayMs,
976
+ maxDelayMs: options.maxDelayMs
977
+ } : void 0;
978
+ let attempt = 0;
979
+ while (true) {
980
+ if (signal?.aborted) throw new Error("Speech synthesis was cancelled.");
981
+ try {
982
+ return await synthesize();
983
+ } catch (error) {
984
+ if (!retry || attempt >= retry.maxRetries || !isRetryable(error)) throw error;
985
+ attempt += 1;
986
+ const delayMs = delayForRetry(retry, attempt);
987
+ onRetry(attempt, delayMs);
988
+ if (delayMs > 0)
989
+ await new Promise((resolve, reject) => {
990
+ const timer = setTimeout(() => {
991
+ signal?.removeEventListener("abort", abort);
992
+ resolve();
993
+ }, delayMs);
994
+ const abort = () => {
995
+ clearTimeout(timer);
996
+ signal?.removeEventListener("abort", abort);
997
+ reject(new Error("Speech synthesis was cancelled."));
998
+ };
999
+ signal?.addEventListener("abort", abort, { once: true });
1000
+ });
1001
+ }
1002
+ }
1003
+ }
1004
+ function sharedValidationOptions(options, signal) {
1005
+ const validator = options.urlValidator ?? options.customUrlValidator;
1006
+ if (!validator) return signal ? withValidationSignal(options, signal) : options;
1007
+ const runner = (0, import_ssml_core2.createAzureUrlValidatorRunner)(validator, {
1008
+ ...options.urlValidation ?? {},
1009
+ ...options.urlValidatorConcurrency !== void 0 ? { concurrency: options.urlValidatorConcurrency } : {},
1010
+ ...options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
1011
+ ...signal ? { signal } : {},
1012
+ ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
1013
+ });
1014
+ return {
1015
+ ...withValidationSignal(options, signal),
1016
+ urlValidatorRunner: runner
1017
+ };
1018
+ }
707
1019
  async function synthesizeSsmlSafe(client, ssml, options = {}) {
708
- const validationOptions = withValidationSignal(options.validation ?? options, options.signal);
1020
+ const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
709
1021
  const diagnostics = await Promise.resolve((0, import_ssml_core2.validateAzureSsml)(ssml, validationOptions));
710
1022
  if (options.signal?.aborted) {
711
1023
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
@@ -732,7 +1044,7 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
732
1044
  }
733
1045
  }
734
1046
  async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
735
- const validationOptions = withValidationSignal(options.validation ?? options, options.signal);
1047
+ const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
736
1048
  if (options.signal?.aborted) {
737
1049
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
738
1050
  return failure(error);
@@ -753,16 +1065,24 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
753
1065
  pending(index, "pending");
754
1066
  });
755
1067
  const validations = await Promise.all(
756
- chunks.map(async (chunk) => {
1068
+ chunks.map(async (chunk, index) => {
757
1069
  const ssml = typeof chunk === "string" ? chunk : chunk.ssml;
758
1070
  const sourceNodePath = typeof chunk === "string" ? options.sourceNodePath : chunk.sourceNodePath ?? options.sourceNodePath;
759
1071
  const diagnostics = await Promise.resolve(
760
- (0, import_ssml_core2.validateAzureSsml)(ssml, { ...validationOptions, ...sourceNodePath ? { sourceNodePath } : {} })
1072
+ (0, import_ssml_core2.validateAzureSsml)(ssml, {
1073
+ ...validationOptions,
1074
+ ...sourceNodePath ? { sourceNodePath } : {},
1075
+ chunkIndex: index
1076
+ })
761
1077
  );
762
1078
  return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
763
1079
  })
764
1080
  );
765
1081
  const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
1082
+ if (options.signal?.aborted) {
1083
+ const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
1084
+ return failure(error);
1085
+ }
766
1086
  if (firstInvalidIndex >= 0) {
767
1087
  const error = new ChunkValidationError(firstInvalidIndex, validations[firstInvalidIndex] ?? []);
768
1088
  pending(firstInvalidIndex, "failed", error);
@@ -779,96 +1099,126 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
779
1099
  outputFormat: options.outputFormat,
780
1100
  signal: options.signal,
781
1101
  timeoutMs: options.timeoutMs,
782
- sourceNodePath: options.sourceNodePath
1102
+ sourceNodePath: options.sourceNodePath,
1103
+ concurrency: options.concurrency,
1104
+ retryOptions: options.retryOptions
783
1105
  });
784
1106
  return { ok: true, success: true, status: "success", value };
785
1107
  }
786
- const results = [];
787
- for (const [index, chunk] of chunks.entries()) {
788
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
789
- const sourceNodePath = input.sourceNodePath;
790
- const originalTextRange = input.originalTextRange;
791
- pending(index, "synthesizing");
792
- const startedAt = Date.now();
793
- try {
794
- const result = await client.synthesizeSsml(input.ssml, {
795
- outputFormat: options.outputFormat,
796
- signal: options.signal,
797
- timeoutMs: options.timeoutMs,
798
- sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
799
- });
800
- results.push({
801
- ...result,
802
- ...input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {},
803
- ...sourceNodePath ? {
804
- boundaries: result.boundaries?.map((event) => ({
805
- ...event,
806
- sourceNodePath: [...sourceNodePath],
807
- ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
808
- })),
809
- visemes: result.visemes?.map((event) => ({
810
- ...event,
811
- sourceNodePath: [...sourceNodePath],
812
- ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
813
- })),
814
- bookmarks: result.bookmarks?.map((event) => ({
815
- ...event,
816
- sourceNodePath: [...sourceNodePath],
817
- ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
818
- }))
819
- } : {},
820
- ...originalTextRange ? {
821
- boundaries: result.boundaries?.map((event) => ({
822
- ...event,
823
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
824
- })),
825
- wordBoundary: result.wordBoundary?.map((event) => ({
826
- ...event,
827
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
828
- })),
829
- wordBoundaries: result.wordBoundaries?.map((event) => ({
830
- ...event,
831
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
832
- })),
833
- visemes: result.visemes?.map((event) => ({
834
- ...event,
835
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
836
- })),
837
- bookmarks: result.bookmarks?.map((event) => ({
838
- ...event,
839
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
840
- }))
841
- } : {}
842
- });
843
- options.onProgress?.({
844
- currentChunk: index + 1,
845
- totalChunks: chunks.length,
846
- percent: chunks.length === 0 ? 100 : Math.round((index + 1) / chunks.length * 100),
847
- chunkIndex: index,
848
- originalTextRange: input.originalTextRange,
849
- status: "success",
850
- durationMs: Date.now() - startedAt
851
- });
852
- } catch (error) {
853
- options.onProgress?.({
854
- currentChunk: index,
855
- totalChunks: chunks.length,
856
- percent: chunks.length === 0 ? 100 : Math.round(index / chunks.length * 100),
857
- chunkIndex: index,
858
- originalTextRange: input.originalTextRange,
859
- status: "failed",
860
- durationMs: Date.now() - startedAt,
861
- error
862
- });
863
- throw error;
1108
+ const results = new Array(chunks.length);
1109
+ let completed = 0;
1110
+ let nextIndex = 0;
1111
+ const concurrency = resolveConcurrency2(options.concurrency, chunks.length);
1112
+ const worker = async () => {
1113
+ while (true) {
1114
+ const index = nextIndex++;
1115
+ if (index >= chunks.length) return;
1116
+ const chunk = chunks[index];
1117
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
1118
+ const sourceNodePath = input.sourceNodePath;
1119
+ const originalTextRange = input.originalTextRange;
1120
+ pending(index, "synthesizing");
1121
+ const startedAt = Date.now();
1122
+ try {
1123
+ const result = await retryableSynthesis(
1124
+ () => client.synthesizeSsml(input.ssml, {
1125
+ outputFormat: options.outputFormat,
1126
+ signal: options.signal,
1127
+ timeoutMs: options.timeoutMs,
1128
+ sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
1129
+ }),
1130
+ options.retryOptions,
1131
+ options.signal,
1132
+ (retryAttempt, nextRetryDelayMs) => options.onProgress?.({
1133
+ currentChunk: completed,
1134
+ totalChunks: chunks.length,
1135
+ percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1136
+ chunkIndex: index,
1137
+ originalTextRange: input.originalTextRange,
1138
+ status: "synthesizing",
1139
+ durationMs: Date.now() - startedAt,
1140
+ retryAttempt,
1141
+ nextRetryDelayMs,
1142
+ isRetrying: true
1143
+ })
1144
+ );
1145
+ results[index] = {
1146
+ ...result,
1147
+ ...input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {},
1148
+ ...sourceNodePath ? {
1149
+ boundaries: result.boundaries?.map((event) => ({
1150
+ ...event,
1151
+ sourceNodePath: [...sourceNodePath],
1152
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1153
+ })),
1154
+ visemes: result.visemes?.map((event) => ({
1155
+ ...event,
1156
+ sourceNodePath: [...sourceNodePath],
1157
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1158
+ })),
1159
+ bookmarks: result.bookmarks?.map((event) => ({
1160
+ ...event,
1161
+ sourceNodePath: [...sourceNodePath],
1162
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1163
+ }))
1164
+ } : {},
1165
+ ...originalTextRange ? {
1166
+ boundaries: result.boundaries?.map((event) => ({
1167
+ ...event,
1168
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1169
+ })),
1170
+ wordBoundary: result.wordBoundary?.map((event) => ({
1171
+ ...event,
1172
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1173
+ })),
1174
+ wordBoundaries: result.wordBoundaries?.map((event) => ({
1175
+ ...event,
1176
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1177
+ })),
1178
+ visemes: result.visemes?.map((event) => ({
1179
+ ...event,
1180
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1181
+ })),
1182
+ bookmarks: result.bookmarks?.map((event) => ({
1183
+ ...event,
1184
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1185
+ }))
1186
+ } : {}
1187
+ };
1188
+ completed += 1;
1189
+ options.onProgress?.({
1190
+ currentChunk: completed,
1191
+ totalChunks: chunks.length,
1192
+ percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1193
+ chunkIndex: index,
1194
+ originalTextRange: input.originalTextRange,
1195
+ status: "success",
1196
+ durationMs: Date.now() - startedAt
1197
+ });
1198
+ } catch (error) {
1199
+ options.onProgress?.({
1200
+ currentChunk: completed,
1201
+ totalChunks: chunks.length,
1202
+ percent: chunks.length === 0 ? 100 : Math.round(index / chunks.length * 100),
1203
+ chunkIndex: index,
1204
+ originalTextRange: input.originalTextRange,
1205
+ status: "failed",
1206
+ durationMs: Date.now() - startedAt,
1207
+ error
1208
+ });
1209
+ throw error;
1210
+ }
864
1211
  }
865
- }
1212
+ };
1213
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
1214
+ const orderedResults = results.filter((result) => result !== void 0);
866
1215
  return {
867
1216
  ok: true,
868
1217
  success: true,
869
1218
  status: "success",
870
- value: mergeSynthesisResults(results, {
871
- format: options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3"
1219
+ value: mergeSynthesisResults(orderedResults, {
1220
+ format: options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3",
1221
+ signal: options.signal
872
1222
  })
873
1223
  };
874
1224
  } catch (error) {
@@ -927,7 +1277,9 @@ var AzureTtsClient = class {
927
1277
  signal: options.signal ?? signal,
928
1278
  timeoutMs: options.timeoutMs ?? timeoutMs,
929
1279
  sourceNodePath: options.sourceNodePath,
930
- onProgress: options.onProgress ?? __privateGet(this, _options).onProgress
1280
+ onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1281
+ concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1282
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
931
1283
  });
932
1284
  }
933
1285
  async synthesizeSsmlSafe(ssml, options = {}) {
@@ -939,7 +1291,9 @@ var AzureTtsClient = class {
939
1291
  outputFormat: options.outputFormat ?? __privateGet(this, _options).outputFormat,
940
1292
  signal: options.signal ?? __privateGet(this, _options).signal,
941
1293
  timeoutMs: options.timeoutMs ?? __privateGet(this, _options).timeoutMs,
942
- onProgress: options.onProgress ?? __privateGet(this, _options).onProgress
1294
+ onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1295
+ concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1296
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
943
1297
  });
944
1298
  }
945
1299
  async synthesizeSsmlChunksSafe(chunks, options = {}) {
@@ -1036,6 +1390,7 @@ async function fetchAzureVoiceCatalog(options) {
1036
1390
  }
1037
1391
  // Annotate the CommonJS export names for ESM import in node:
1038
1392
  0 && (module.exports = {
1393
+ AudioFormatMismatchError,
1039
1394
  AzureTtsClient,
1040
1395
  AzureTtsError,
1041
1396
  AzureTtsSdkError,
@@ -1047,6 +1402,7 @@ async function fetchAzureVoiceCatalog(options) {
1047
1402
  UnsupportedMergeFormatError,
1048
1403
  canMergeAudioFormat,
1049
1404
  fetchAzureVoiceCatalog,
1405
+ inspectAudioSpecification,
1050
1406
  mergeAudioBuffers,
1051
1407
  mergeSynthesisResults,
1052
1408
  resolveMergeAudioFormat,