ssml-builder-js 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.mjs CHANGED
@@ -15,13 +15,15 @@ import {
15
15
  parseSsml,
16
16
  splitSsmlDocument,
17
17
  validateAzureSsml,
18
+ validateAzureSsmlChunks,
18
19
  validateSsml,
19
20
  validateSsmlStructureIntegrity
20
- } from "./chunk-FXUM45ZY.mjs";
21
+ } from "./chunk-NKLZGITR.mjs";
21
22
  import {
23
+ createAzureUrlValidatorRunner as createAzureUrlValidatorRunner2,
22
24
  getSsmlSourceMap as getSsmlSourceMap2,
23
25
  validateAzureSsml as validateAzureSsml2
24
- } from "./chunk-WXFLUCLR.mjs";
26
+ } from "./chunk-HI74FTKY.mjs";
25
27
  import {
26
28
  __privateAdd,
27
29
  __privateGet,
@@ -70,6 +72,14 @@ var MergeError = class extends Error {
70
72
  this.cause = cause;
71
73
  }
72
74
  };
75
+ var AudioFormatMismatchError = class extends Error {
76
+ constructor(message, inputSpecs = []) {
77
+ super(message);
78
+ this.kind = "audio-format-mismatch";
79
+ this.name = "AudioFormatMismatchError";
80
+ this.inputSpecs = inputSpecs;
81
+ }
82
+ };
73
83
  var UnsupportedMergeFormatError = class extends Error {
74
84
  constructor(format) {
75
85
  super(`Audio format "${format}" cannot be safely concatenated; container re-multiplexing is required.`);
@@ -79,7 +89,7 @@ var UnsupportedMergeFormatError = class extends Error {
79
89
  }
80
90
  };
81
91
  function toSynthesisError(error) {
82
- if (error instanceof AzureTtsError || error instanceof MergeError || error instanceof UnsupportedMergeFormatError || error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError)
92
+ if (error instanceof AzureTtsError || error instanceof MergeError || error instanceof AudioFormatMismatchError || error instanceof UnsupportedMergeFormatError || error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError)
83
93
  return error;
84
94
  const message = error instanceof Error ? error.message : String(error);
85
95
  if (/cancel|abort/i.test(message)) return new SynthesisCancelledError(message);
@@ -208,6 +218,105 @@ function parseWav(buffer) {
208
218
  }
209
219
  return { chunks, data, format };
210
220
  }
221
+ function formatNumber(format, pattern, fallback) {
222
+ const match = pattern.exec(format);
223
+ return match?.[1] ? Number(match[1]) : fallback;
224
+ }
225
+ function formatChannels(format, fallback) {
226
+ if (/stereo|2ch|dual/i.test(format)) return 2;
227
+ if (/mono|1ch/i.test(format)) return 1;
228
+ return fallback;
229
+ }
230
+ function formatAudioSpecification(format) {
231
+ const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
232
+ const channels = formatChannels(format, 0);
233
+ const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
234
+ const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1e3 : void 0;
235
+ 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";
236
+ return {
237
+ format,
238
+ mimeType: resolveMimeType(format),
239
+ codec,
240
+ sampleRate,
241
+ channels,
242
+ ...bitrate ? { bitrate } : {},
243
+ isCompressed: codec === "mp3" || codec === "opus" || codec === "silk"
244
+ };
245
+ }
246
+ function parseMp3Specification(buffer, format) {
247
+ const bytes = stripMp3Tags(buffer);
248
+ const bitrates = [
249
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
250
+ [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0],
251
+ [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0]
252
+ ];
253
+ const sampleRates = [
254
+ [44100, 48e3, 32e3],
255
+ [22050, 24e3, 16e3],
256
+ [11025, 12e3, 8e3]
257
+ ];
258
+ for (let index = 0; index + 4 <= bytes.length; index += 1) {
259
+ if (bytes[index] !== 255 || (bytes[index + 1] ?? 0) < 224) continue;
260
+ const header = bytes[index + 1] ?? 0;
261
+ const versionBits = header >> 3 & 3;
262
+ const layer = header >> 1 & 3;
263
+ const bitrateIndex = (bytes[index + 2] ?? 0) >> 4;
264
+ const sampleIndex = (bytes[index + 2] ?? 0) >> 2 & 3;
265
+ if (versionBits === 1 || layer !== 1 || bitrateIndex === 0 || bitrateIndex === 15 || sampleIndex === 3) continue;
266
+ const versionIndex = versionBits === 3 ? 0 : versionBits === 2 ? 1 : 2;
267
+ const bitrateTable = versionBits === 3 ? bitrates[1] : bitrates[2];
268
+ const sampleRate = sampleRates[versionIndex]?.[sampleIndex] ?? 0;
269
+ const bitrateKbps = bitrateTable?.[bitrateIndex] ?? 0;
270
+ if (!sampleRate || !bitrateKbps) continue;
271
+ return {
272
+ format,
273
+ mimeType: "audio/mpeg",
274
+ codec: "mp3",
275
+ sampleRate,
276
+ channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
277
+ bitrate: bitrateKbps * 1e3,
278
+ isCompressed: true
279
+ };
280
+ }
281
+ return void 0;
282
+ }
283
+ function inspectAudioSpecification(buffer, format) {
284
+ if (isWavFormat(format) || ascii(new Uint8Array(buffer), 0, "RIFF")) {
285
+ const parsed = parseWav(buffer);
286
+ if (parsed.format.byteLength < 16) throw new Error("Invalid WAV fmt chunk.");
287
+ const view = new DataView(parsed.format.buffer, parsed.format.byteOffset, parsed.format.byteLength);
288
+ const sampleRate = view.getUint32(4, true);
289
+ const channels = view.getUint16(2, true);
290
+ const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
291
+ const formatCode = view.getUint16(0, true);
292
+ return {
293
+ format,
294
+ mimeType: "audio/wav",
295
+ codec: formatCode === 1 ? "pcm" : "unknown",
296
+ sampleRate,
297
+ channels,
298
+ ...sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {},
299
+ isCompressed: formatCode !== 1
300
+ };
301
+ }
302
+ if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
303
+ return formatAudioSpecification(format);
304
+ }
305
+ function validateAudioSpecifications(specs) {
306
+ const first = specs[0];
307
+ if (!first) return;
308
+ const mismatch = specs.find(
309
+ (spec) => spec.sampleRate !== first.sampleRate || spec.channels !== first.channels || first.bitrate !== void 0 && spec.bitrate !== void 0 && spec.bitrate !== first.bitrate
310
+ );
311
+ if (mismatch)
312
+ throw new AudioFormatMismatchError(
313
+ `Audio chunks have incompatible specifications: ${first.sampleRate}Hz/${first.channels}ch versus ${mismatch.sampleRate}Hz/${mismatch.channels}ch.`,
314
+ specs
315
+ );
316
+ }
317
+ function isAudioFormatMismatch(error) {
318
+ return error instanceof AudioFormatMismatchError || error !== null && typeof error === "object" && "kind" in error && error.kind === "audio-format-mismatch";
319
+ }
211
320
  function writeUint32(target, offset, value) {
212
321
  new DataView(target.buffer).setUint32(offset, value, true);
213
322
  }
@@ -293,6 +402,7 @@ function mergeAudioBuffers(buffers, options) {
293
402
  const format = typeof options === "string" ? options : options?.format;
294
403
  if (!format) throw new UnsupportedMergeFormatError("");
295
404
  try {
405
+ validateAudioSpecifications(buffers.map((buffer) => inspectAudioSpecification(buffer, format)));
296
406
  if (isWavFormat(format)) return mergeWavBuffers(buffers);
297
407
  if (isMp3Format(format)) {
298
408
  const parts = buffers.map(stripMp3Tags);
@@ -315,7 +425,8 @@ function mergeAudioBuffers(buffers, options) {
315
425
  }
316
426
  throw new UnsupportedMergeFormatError(format);
317
427
  } catch (error) {
318
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
428
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
429
+ throw error;
319
430
  throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
320
431
  }
321
432
  }
@@ -393,17 +504,26 @@ async function synthesizeSsml(ssml, config) {
393
504
  return {
394
505
  originalTextRange: { ...marker.originalTextRange },
395
506
  sourceNodePath: [...marker.sourceNodePath],
396
- textRange: { ...marker.originalTextRange }
507
+ textRange: { ...marker.originalTextRange },
508
+ mappingStatus: "exact"
397
509
  };
398
510
  }
399
- if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) return {};
511
+ if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) {
512
+ const unmapped = { mappingStatus: "unmapped" };
513
+ Object.defineProperty(unmapped, "mappingStatus", { value: "unmapped", enumerable: false });
514
+ return unmapped;
515
+ }
400
516
  const value = text ?? "";
401
517
  let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? offsetHint : -1;
402
- if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value)
518
+ let mappingStatus = "exact";
519
+ if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value) {
403
520
  localStart = -1;
521
+ mappingStatus = "fallback";
522
+ }
404
523
  if (localStart < 0 || localStart > sourceText.length) {
405
524
  localStart = value ? sourceText.indexOf(value, sourceEventCursor) : sourceEventCursor;
406
525
  if (localStart < 0) localStart = value ? sourceText.indexOf(value) : sourceEventCursor;
526
+ mappingStatus = "fallback";
407
527
  }
408
528
  localStart = Math.max(0, localStart);
409
529
  const localEnd = Math.min(sourceText.length, localStart + value.length);
@@ -414,7 +534,8 @@ async function synthesizeSsml(ssml, config) {
414
534
  return {
415
535
  originalTextRange: { ...fallbackRange },
416
536
  textRange: { ...fallbackRange },
417
- ...segment ? { sourceNodePath: [...segment.sourceNodePath] } : config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {}
537
+ ...segment ? { sourceNodePath: [...segment.sourceNodePath] } : config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
538
+ mappingStatus: segment || config.sourceTextRange || config.sourceNodePath ? mappingStatus : "unmapped"
418
539
  };
419
540
  };
420
541
  synthesizer.wordBoundary = (_sender, event) => {
@@ -463,20 +584,27 @@ async function synthesizeSsml(ssml, config) {
463
584
  );
464
585
  const durationMs = result.audioDuration ? ticksToMilliseconds(result.audioDuration) : eventDurationMs;
465
586
  const requestId = result.resultId;
466
- const addSourceMetadata = (event) => ({
467
- ...event,
468
- ...config.sourceTextRange && !("textRange" in event) ? { textRange: { ...config.sourceTextRange } } : {},
469
- ...config.sourceTextRange && !("originalTextRange" in event) ? { originalTextRange: { ...config.sourceTextRange } } : {},
470
- ...config.chunkIndex !== void 0 ? { chunkIndex: config.chunkIndex } : {},
471
- ...config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
472
- ...requestId ? { requestId } : {}
473
- });
587
+ const addSourceMetadata = (event) => {
588
+ const mapped = {
589
+ ...event,
590
+ ...config.sourceTextRange && !("textRange" in event) ? { textRange: { ...config.sourceTextRange } } : {},
591
+ ...config.sourceTextRange && !("originalTextRange" in event) ? { originalTextRange: { ...config.sourceTextRange } } : {},
592
+ ...config.chunkIndex !== void 0 ? { chunkIndex: config.chunkIndex } : {},
593
+ ...config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
594
+ ...requestId ? { requestId } : {}
595
+ };
596
+ if (event.mappingStatus === "unmapped")
597
+ Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
598
+ return mapped;
599
+ };
474
600
  const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
475
601
  const sourceVisemes = visemes.map((viseme) => addSourceMetadata(viseme));
476
602
  const sourceBookmarks = bookmarks.map((bookmark) => addSourceMetadata(bookmark));
477
603
  resolve({
478
604
  audioData: result.audioData,
479
605
  durationMs,
606
+ audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
607
+ mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
480
608
  ...config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {},
481
609
  ...requestId ? { requestId } : {},
482
610
  ...sourceBoundaries.length > 0 ? { boundaries: sourceBoundaries, wordBoundary: sourceBoundaries, wordBoundaries: sourceBoundaries } : {},
@@ -501,8 +629,66 @@ async function synthesizeSsml(ssml, config) {
501
629
  }
502
630
  });
503
631
  }
632
+ function isRetryableSynthesisError(error) {
633
+ if (error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError) return false;
634
+ if (error instanceof AzureTtsError && error.status !== 0)
635
+ return error.status === 429 || error.status >= 500 && error.status < 600;
636
+ const message = error instanceof Error ? error.message : String(error);
637
+ if (/\b4\d{2}\b/.test(message)) return false;
638
+ const status = error && typeof error === "object" && "status" in error ? error.status : void 0;
639
+ if (typeof status === "number") return status === 429 || status >= 500 && status < 600;
640
+ return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
641
+ }
642
+ function retryDelay(options, retryAttempt) {
643
+ const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
644
+ return Math.floor(Math.random() * (base + 1));
645
+ }
646
+ function resolveConcurrency(value, total) {
647
+ if (value === void 0) return 1;
648
+ if (value === Infinity) return Math.max(1, total);
649
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
650
+ }
651
+ async function waitForRetry(delayMs, signal) {
652
+ if (signal?.aborted) throw new SynthesisCancelledError();
653
+ if (delayMs <= 0) return;
654
+ await new Promise((resolve, reject) => {
655
+ let timer;
656
+ const abort = () => {
657
+ clearTimeout(timer);
658
+ signal?.removeEventListener("abort", abort);
659
+ reject(new SynthesisCancelledError());
660
+ };
661
+ timer = setTimeout(() => {
662
+ signal?.removeEventListener("abort", abort);
663
+ resolve();
664
+ }, delayMs);
665
+ if (signal) {
666
+ signal.addEventListener("abort", abort, { once: true });
667
+ }
668
+ });
669
+ }
670
+ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
671
+ const options = retryOptions ? {
672
+ maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
673
+ initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
674
+ maxDelayMs: Math.max(0, retryOptions.maxDelayMs)
675
+ } : void 0;
676
+ let attempt = 0;
677
+ while (true) {
678
+ if (config.signal?.aborted) throw new SynthesisCancelledError();
679
+ try {
680
+ return await synthesizeSsml(ssml, config);
681
+ } catch (error) {
682
+ if (!options || attempt >= options.maxRetries || !isRetryableSynthesisError(error)) throw error;
683
+ attempt += 1;
684
+ const delayMs = retryDelay(options, attempt);
685
+ onRetry(attempt, delayMs);
686
+ await waitForRetry(delayMs, config.signal);
687
+ }
688
+ }
689
+ }
504
690
  async function synthesizeSsmlChunks(chunks, config) {
505
- const results = [];
691
+ const results = new Array(chunks.length);
506
692
  const totalChunks = chunks.length;
507
693
  const report = (event) => config.onProgress?.(event);
508
694
  for (const [index, chunk] of chunks.entries()) {
@@ -517,57 +703,85 @@ async function synthesizeSsmlChunks(chunks, config) {
517
703
  durationMs: 0
518
704
  });
519
705
  }
520
- for (const [index, chunk] of chunks.entries()) {
521
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
522
- report({
523
- currentChunk: index,
524
- totalChunks,
525
- percent: totalChunks === 0 ? 100 : Math.round(index / totalChunks * 100),
526
- chunkIndex: index,
527
- originalTextRange: input.originalTextRange,
528
- status: "synthesizing",
529
- durationMs: 0
530
- });
531
- const startedAt = Date.now();
532
- try {
533
- const result = await synthesizeSsml(input.ssml, {
534
- ...config,
535
- ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
536
- ...input.sourceNodePath ?? config.sourceNodePath ? { sourceNodePath: [...input.sourceNodePath ?? config.sourceNodePath ?? []] } : {},
537
- ...input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {},
538
- ...input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {},
539
- chunkIndex: index,
540
- onProgress: void 0
541
- });
542
- results.push(result);
543
- report({
544
- currentChunk: index + 1,
545
- totalChunks,
546
- percent: totalChunks === 0 ? 100 : Math.round((index + 1) / totalChunks * 100),
547
- chunkIndex: index,
548
- originalTextRange: input.originalTextRange,
549
- status: "success",
550
- durationMs: Date.now() - startedAt
551
- });
552
- } catch (error) {
706
+ let completed = 0;
707
+ let nextIndex = 0;
708
+ const concurrency = resolveConcurrency(config.concurrency, chunks.length);
709
+ const worker = async () => {
710
+ while (true) {
711
+ const index = nextIndex++;
712
+ if (index >= chunks.length) return;
713
+ const chunk = chunks[index];
714
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
553
715
  report({
554
- currentChunk: index,
716
+ currentChunk: completed,
555
717
  totalChunks,
556
- percent: totalChunks === 0 ? 100 : Math.round(index / totalChunks * 100),
718
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
557
719
  chunkIndex: index,
558
720
  originalTextRange: input.originalTextRange,
559
- status: "failed",
560
- durationMs: Date.now() - startedAt,
561
- error
721
+ status: "synthesizing",
722
+ durationMs: 0
562
723
  });
563
- throw error;
724
+ const startedAt = Date.now();
725
+ try {
726
+ const result = await synthesizeWithRetry(
727
+ input.ssml,
728
+ {
729
+ ...config,
730
+ ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
731
+ ...input.sourceNodePath ?? config.sourceNodePath ? { sourceNodePath: [...input.sourceNodePath ?? config.sourceNodePath ?? []] } : {},
732
+ ...input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {},
733
+ ...input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {},
734
+ chunkIndex: index,
735
+ onProgress: void 0
736
+ },
737
+ config.retryOptions,
738
+ (retryAttempt, nextRetryDelayMs) => report({
739
+ currentChunk: completed,
740
+ totalChunks,
741
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
742
+ chunkIndex: index,
743
+ originalTextRange: input.originalTextRange,
744
+ status: "synthesizing",
745
+ durationMs: Date.now() - startedAt,
746
+ retryAttempt,
747
+ nextRetryDelayMs,
748
+ isRetrying: true
749
+ })
750
+ );
751
+ results[index] = result;
752
+ completed += 1;
753
+ report({
754
+ currentChunk: completed,
755
+ totalChunks,
756
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
757
+ chunkIndex: index,
758
+ originalTextRange: input.originalTextRange,
759
+ status: "success",
760
+ durationMs: Date.now() - startedAt
761
+ });
762
+ } catch (error) {
763
+ report({
764
+ currentChunk: completed,
765
+ totalChunks,
766
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
767
+ chunkIndex: index,
768
+ originalTextRange: input.originalTextRange,
769
+ status: "failed",
770
+ durationMs: Date.now() - startedAt,
771
+ error
772
+ });
773
+ throw error;
774
+ }
564
775
  }
565
- }
566
- return mergeSynthesisResults(results, {
567
- format: config.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3"
776
+ };
777
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
778
+ const orderedResults = results.filter((result) => result !== void 0);
779
+ return mergeSynthesisResults(orderedResults, {
780
+ format: config.outputFormat ?? DEFAULT_OUTPUT_FORMAT,
781
+ signal: config.signal
568
782
  });
569
783
  }
570
- function createMergedResult(results, audioData, format) {
784
+ function createMergedResult(results, audioData, format, audioSpec, outputMimeType) {
571
785
  const boundaries = [];
572
786
  const visemes = [];
573
787
  const bookmarks = [];
@@ -586,7 +800,8 @@ function createMergedResult(results, audioData, format) {
586
800
  ...boundary.sourceNodePath ? { sourceNodePath: [...boundary.sourceNodePath] } : {},
587
801
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
588
802
  ...textRange ? { textRange: { ...textRange } } : {},
589
- ...requestId ? { requestId } : {}
803
+ ...requestId ? { requestId } : {},
804
+ mappingStatus: boundary.mappingStatus ?? "unmapped"
590
805
  });
591
806
  }
592
807
  for (const viseme of result.visemes ?? []) {
@@ -601,7 +816,8 @@ function createMergedResult(results, audioData, format) {
601
816
  ...viseme.sourceNodePath ? { sourceNodePath: [...viseme.sourceNodePath] } : {},
602
817
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
603
818
  ...textRange ? { textRange: { ...textRange } } : {},
604
- ...requestId ? { requestId } : {}
819
+ ...requestId ? { requestId } : {},
820
+ mappingStatus: viseme.mappingStatus ?? "unmapped"
605
821
  });
606
822
  }
607
823
  for (const bookmark of result.bookmarks ?? []) {
@@ -616,7 +832,8 @@ function createMergedResult(results, audioData, format) {
616
832
  ...bookmark.sourceNodePath ? { sourceNodePath: [...bookmark.sourceNodePath] } : {},
617
833
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
618
834
  ...textRange ? { textRange: { ...textRange } } : {},
619
- ...requestId ? { requestId } : {}
835
+ ...requestId ? { requestId } : {},
836
+ mappingStatus: bookmark.mappingStatus ?? "unmapped"
620
837
  });
621
838
  }
622
839
  durationOffset += Math.max(0, result.durationMs);
@@ -625,6 +842,8 @@ function createMergedResult(results, audioData, format) {
625
842
  audioData,
626
843
  durationMs: durationOffset,
627
844
  mimeType: resolveMimeType(format),
845
+ audioSpec: audioSpec ?? formatAudioSpecification(format),
846
+ ...outputMimeType ? { mimeType: outputMimeType } : {},
628
847
  ...boundaries.length > 0 ? { boundaries, wordBoundary: boundaries, wordBoundaries: boundaries } : {},
629
848
  ...visemes.length > 0 ? { visemes } : {},
630
849
  ...bookmarks.length > 0 ? { bookmarks } : {},
@@ -637,19 +856,47 @@ function mergeSynthesisResults(results, options) {
637
856
  const format = resolvedOptions?.format;
638
857
  if (!format) throw new UnsupportedMergeFormatError("");
639
858
  const buffers = results.map((result) => result.audioData);
859
+ const inputSpecs = results.map((result) => result.audioSpec ?? inspectAudioSpecification(result.audioData, format));
860
+ validateAudioSpecifications(inputSpecs);
861
+ const signal = resolvedOptions.signal ?? new AbortController().signal;
862
+ if (signal.aborted) throw new SynthesisCancelledError();
640
863
  if (resolvedOptions.customMerger) {
641
- return Promise.resolve().then(() => resolvedOptions.customMerger?.(buffers, format)).then((merged) => {
864
+ return Promise.resolve().then(
865
+ () => resolvedOptions.customMerger?.(buffers, {
866
+ format,
867
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
868
+ inputSpecs,
869
+ signal
870
+ })
871
+ ).then((merged) => {
642
872
  if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
643
- return createMergedResult(results, merged, format);
873
+ if (!(merged instanceof ArrayBuffer) || buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
874
+ throw new MergeError("The custom audio merger returned an invalid audio buffer.");
875
+ if (signal.aborted) throw new SynthesisCancelledError();
876
+ return createMergedResult(
877
+ results,
878
+ merged,
879
+ format,
880
+ inspectAudioSpecification(merged, format),
881
+ resolvedOptions.outputMimeType
882
+ );
644
883
  }).catch((error) => {
645
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
884
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
885
+ throw error;
646
886
  throw new MergeError(`Custom audio merger failed for format "${format}".`, error);
647
887
  });
648
888
  }
649
889
  try {
650
- return createMergedResult(results, mergeAudioBuffers(buffers, { format }), format);
890
+ return createMergedResult(
891
+ results,
892
+ mergeAudioBuffers(buffers, { format }),
893
+ format,
894
+ inputSpecs[0],
895
+ resolvedOptions.outputMimeType
896
+ );
651
897
  } catch (error) {
652
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
898
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
899
+ throw error;
653
900
  throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
654
901
  }
655
902
  }
@@ -670,8 +917,73 @@ var ChunkValidationError = class extends Error {
670
917
  function failure(error) {
671
918
  return { ok: false, success: false, status: error.kind, error };
672
919
  }
920
+ function isRetryable(error) {
921
+ if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
922
+ const status = error && typeof error === "object" && "status" in error ? error.status : void 0;
923
+ if (typeof status === "number" && status !== 0) return status === 429 || status >= 500 && status < 600;
924
+ const message = error instanceof Error ? error.message : String(error);
925
+ if (/\b4\d{2}\b/.test(message)) return false;
926
+ return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
927
+ }
928
+ function delayForRetry(options, attempt) {
929
+ const maxDelay = Math.max(0, options.maxDelayMs);
930
+ const base = Math.min(maxDelay, Math.max(0, options.initialDelayMs) * 2 ** Math.max(0, attempt - 1));
931
+ return Math.floor(Math.random() * (base + 1));
932
+ }
933
+ function resolveConcurrency2(value, total) {
934
+ if (value === void 0) return 1;
935
+ if (value === Infinity) return Math.max(1, total);
936
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
937
+ }
938
+ async function retryableSynthesis(synthesize, options, signal, onRetry) {
939
+ const retry = options ? {
940
+ maxRetries: Math.max(0, Math.floor(options.maxRetries)),
941
+ initialDelayMs: options.initialDelayMs,
942
+ maxDelayMs: options.maxDelayMs
943
+ } : void 0;
944
+ let attempt = 0;
945
+ while (true) {
946
+ if (signal?.aborted) throw new Error("Speech synthesis was cancelled.");
947
+ try {
948
+ return await synthesize();
949
+ } catch (error) {
950
+ if (!retry || attempt >= retry.maxRetries || !isRetryable(error)) throw error;
951
+ attempt += 1;
952
+ const delayMs = delayForRetry(retry, attempt);
953
+ onRetry(attempt, delayMs);
954
+ if (delayMs > 0)
955
+ await new Promise((resolve, reject) => {
956
+ const timer = setTimeout(() => {
957
+ signal?.removeEventListener("abort", abort);
958
+ resolve();
959
+ }, delayMs);
960
+ const abort = () => {
961
+ clearTimeout(timer);
962
+ signal?.removeEventListener("abort", abort);
963
+ reject(new Error("Speech synthesis was cancelled."));
964
+ };
965
+ signal?.addEventListener("abort", abort, { once: true });
966
+ });
967
+ }
968
+ }
969
+ }
970
+ function sharedValidationOptions(options, signal) {
971
+ const validator = options.urlValidator ?? options.customUrlValidator;
972
+ if (!validator) return signal ? withValidationSignal(options, signal) : options;
973
+ const runner = createAzureUrlValidatorRunner2(validator, {
974
+ ...options.urlValidation ?? {},
975
+ ...options.urlValidatorConcurrency !== void 0 ? { concurrency: options.urlValidatorConcurrency } : {},
976
+ ...options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
977
+ ...signal ? { signal } : {},
978
+ ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
979
+ });
980
+ return {
981
+ ...withValidationSignal(options, signal),
982
+ urlValidatorRunner: runner
983
+ };
984
+ }
673
985
  async function synthesizeSsmlSafe(client, ssml, options = {}) {
674
- const validationOptions = withValidationSignal(options.validation ?? options, options.signal);
986
+ const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
675
987
  const diagnostics = await Promise.resolve(validateAzureSsml2(ssml, validationOptions));
676
988
  if (options.signal?.aborted) {
677
989
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
@@ -698,7 +1010,7 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
698
1010
  }
699
1011
  }
700
1012
  async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
701
- const validationOptions = withValidationSignal(options.validation ?? options, options.signal);
1013
+ const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
702
1014
  if (options.signal?.aborted) {
703
1015
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
704
1016
  return failure(error);
@@ -719,16 +1031,24 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
719
1031
  pending(index, "pending");
720
1032
  });
721
1033
  const validations = await Promise.all(
722
- chunks.map(async (chunk) => {
1034
+ chunks.map(async (chunk, index) => {
723
1035
  const ssml = typeof chunk === "string" ? chunk : chunk.ssml;
724
1036
  const sourceNodePath = typeof chunk === "string" ? options.sourceNodePath : chunk.sourceNodePath ?? options.sourceNodePath;
725
1037
  const diagnostics = await Promise.resolve(
726
- validateAzureSsml2(ssml, { ...validationOptions, ...sourceNodePath ? { sourceNodePath } : {} })
1038
+ validateAzureSsml2(ssml, {
1039
+ ...validationOptions,
1040
+ ...sourceNodePath ? { sourceNodePath } : {},
1041
+ chunkIndex: index
1042
+ })
727
1043
  );
728
1044
  return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
729
1045
  })
730
1046
  );
731
1047
  const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
1048
+ if (options.signal?.aborted) {
1049
+ const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
1050
+ return failure(error);
1051
+ }
732
1052
  if (firstInvalidIndex >= 0) {
733
1053
  const error = new ChunkValidationError(firstInvalidIndex, validations[firstInvalidIndex] ?? []);
734
1054
  pending(firstInvalidIndex, "failed", error);
@@ -745,96 +1065,126 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
745
1065
  outputFormat: options.outputFormat,
746
1066
  signal: options.signal,
747
1067
  timeoutMs: options.timeoutMs,
748
- sourceNodePath: options.sourceNodePath
1068
+ sourceNodePath: options.sourceNodePath,
1069
+ concurrency: options.concurrency,
1070
+ retryOptions: options.retryOptions
749
1071
  });
750
1072
  return { ok: true, success: true, status: "success", value };
751
1073
  }
752
- const results = [];
753
- for (const [index, chunk] of chunks.entries()) {
754
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
755
- const sourceNodePath = input.sourceNodePath;
756
- const originalTextRange = input.originalTextRange;
757
- pending(index, "synthesizing");
758
- const startedAt = Date.now();
759
- try {
760
- const result = await client.synthesizeSsml(input.ssml, {
761
- outputFormat: options.outputFormat,
762
- signal: options.signal,
763
- timeoutMs: options.timeoutMs,
764
- sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
765
- });
766
- results.push({
767
- ...result,
768
- ...input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {},
769
- ...sourceNodePath ? {
770
- boundaries: result.boundaries?.map((event) => ({
771
- ...event,
772
- sourceNodePath: [...sourceNodePath],
773
- ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
774
- })),
775
- visemes: result.visemes?.map((event) => ({
776
- ...event,
777
- sourceNodePath: [...sourceNodePath],
778
- ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
779
- })),
780
- bookmarks: result.bookmarks?.map((event) => ({
781
- ...event,
782
- sourceNodePath: [...sourceNodePath],
783
- ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
784
- }))
785
- } : {},
786
- ...originalTextRange ? {
787
- boundaries: result.boundaries?.map((event) => ({
788
- ...event,
789
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
790
- })),
791
- wordBoundary: result.wordBoundary?.map((event) => ({
792
- ...event,
793
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
794
- })),
795
- wordBoundaries: result.wordBoundaries?.map((event) => ({
796
- ...event,
797
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
798
- })),
799
- visemes: result.visemes?.map((event) => ({
800
- ...event,
801
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
802
- })),
803
- bookmarks: result.bookmarks?.map((event) => ({
804
- ...event,
805
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
806
- }))
807
- } : {}
808
- });
809
- options.onProgress?.({
810
- currentChunk: index + 1,
811
- totalChunks: chunks.length,
812
- percent: chunks.length === 0 ? 100 : Math.round((index + 1) / chunks.length * 100),
813
- chunkIndex: index,
814
- originalTextRange: input.originalTextRange,
815
- status: "success",
816
- durationMs: Date.now() - startedAt
817
- });
818
- } catch (error) {
819
- options.onProgress?.({
820
- currentChunk: index,
821
- totalChunks: chunks.length,
822
- percent: chunks.length === 0 ? 100 : Math.round(index / chunks.length * 100),
823
- chunkIndex: index,
824
- originalTextRange: input.originalTextRange,
825
- status: "failed",
826
- durationMs: Date.now() - startedAt,
827
- error
828
- });
829
- throw error;
1074
+ const results = new Array(chunks.length);
1075
+ let completed = 0;
1076
+ let nextIndex = 0;
1077
+ const concurrency = resolveConcurrency2(options.concurrency, chunks.length);
1078
+ const worker = async () => {
1079
+ while (true) {
1080
+ const index = nextIndex++;
1081
+ if (index >= chunks.length) return;
1082
+ const chunk = chunks[index];
1083
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
1084
+ const sourceNodePath = input.sourceNodePath;
1085
+ const originalTextRange = input.originalTextRange;
1086
+ pending(index, "synthesizing");
1087
+ const startedAt = Date.now();
1088
+ try {
1089
+ const result = await retryableSynthesis(
1090
+ () => client.synthesizeSsml(input.ssml, {
1091
+ outputFormat: options.outputFormat,
1092
+ signal: options.signal,
1093
+ timeoutMs: options.timeoutMs,
1094
+ sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
1095
+ }),
1096
+ options.retryOptions,
1097
+ options.signal,
1098
+ (retryAttempt, nextRetryDelayMs) => options.onProgress?.({
1099
+ currentChunk: completed,
1100
+ totalChunks: chunks.length,
1101
+ percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1102
+ chunkIndex: index,
1103
+ originalTextRange: input.originalTextRange,
1104
+ status: "synthesizing",
1105
+ durationMs: Date.now() - startedAt,
1106
+ retryAttempt,
1107
+ nextRetryDelayMs,
1108
+ isRetrying: true
1109
+ })
1110
+ );
1111
+ results[index] = {
1112
+ ...result,
1113
+ ...input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {},
1114
+ ...sourceNodePath ? {
1115
+ boundaries: result.boundaries?.map((event) => ({
1116
+ ...event,
1117
+ sourceNodePath: [...sourceNodePath],
1118
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1119
+ })),
1120
+ visemes: result.visemes?.map((event) => ({
1121
+ ...event,
1122
+ sourceNodePath: [...sourceNodePath],
1123
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1124
+ })),
1125
+ bookmarks: result.bookmarks?.map((event) => ({
1126
+ ...event,
1127
+ sourceNodePath: [...sourceNodePath],
1128
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1129
+ }))
1130
+ } : {},
1131
+ ...originalTextRange ? {
1132
+ boundaries: result.boundaries?.map((event) => ({
1133
+ ...event,
1134
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1135
+ })),
1136
+ wordBoundary: result.wordBoundary?.map((event) => ({
1137
+ ...event,
1138
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1139
+ })),
1140
+ wordBoundaries: result.wordBoundaries?.map((event) => ({
1141
+ ...event,
1142
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1143
+ })),
1144
+ visemes: result.visemes?.map((event) => ({
1145
+ ...event,
1146
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1147
+ })),
1148
+ bookmarks: result.bookmarks?.map((event) => ({
1149
+ ...event,
1150
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1151
+ }))
1152
+ } : {}
1153
+ };
1154
+ completed += 1;
1155
+ options.onProgress?.({
1156
+ currentChunk: completed,
1157
+ totalChunks: chunks.length,
1158
+ percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1159
+ chunkIndex: index,
1160
+ originalTextRange: input.originalTextRange,
1161
+ status: "success",
1162
+ durationMs: Date.now() - startedAt
1163
+ });
1164
+ } catch (error) {
1165
+ options.onProgress?.({
1166
+ currentChunk: completed,
1167
+ totalChunks: chunks.length,
1168
+ percent: chunks.length === 0 ? 100 : Math.round(index / chunks.length * 100),
1169
+ chunkIndex: index,
1170
+ originalTextRange: input.originalTextRange,
1171
+ status: "failed",
1172
+ durationMs: Date.now() - startedAt,
1173
+ error
1174
+ });
1175
+ throw error;
1176
+ }
830
1177
  }
831
- }
1178
+ };
1179
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
1180
+ const orderedResults = results.filter((result) => result !== void 0);
832
1181
  return {
833
1182
  ok: true,
834
1183
  success: true,
835
1184
  status: "success",
836
- value: mergeSynthesisResults(results, {
837
- format: options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3"
1185
+ value: mergeSynthesisResults(orderedResults, {
1186
+ format: options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3",
1187
+ signal: options.signal
838
1188
  })
839
1189
  };
840
1190
  } catch (error) {
@@ -893,7 +1243,9 @@ var AzureTtsClient = class {
893
1243
  signal: options.signal ?? signal,
894
1244
  timeoutMs: options.timeoutMs ?? timeoutMs,
895
1245
  sourceNodePath: options.sourceNodePath,
896
- onProgress: options.onProgress ?? __privateGet(this, _options).onProgress
1246
+ onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1247
+ concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1248
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
897
1249
  });
898
1250
  }
899
1251
  async synthesizeSsmlSafe(ssml, options = {}) {
@@ -905,7 +1257,9 @@ var AzureTtsClient = class {
905
1257
  outputFormat: options.outputFormat ?? __privateGet(this, _options).outputFormat,
906
1258
  signal: options.signal ?? __privateGet(this, _options).signal,
907
1259
  timeoutMs: options.timeoutMs ?? __privateGet(this, _options).timeoutMs,
908
- onProgress: options.onProgress ?? __privateGet(this, _options).onProgress
1260
+ onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1261
+ concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1262
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
909
1263
  });
910
1264
  }
911
1265
  async synthesizeSsmlChunksSafe(chunks, options = {}) {
@@ -1001,6 +1355,7 @@ async function fetchAzureVoiceCatalog(options) {
1001
1355
  };
1002
1356
  }
1003
1357
  export {
1358
+ AudioFormatMismatchError,
1004
1359
  AzureTtsClient,
1005
1360
  AzureTtsError,
1006
1361
  AzureTtsSdkError,
@@ -1022,6 +1377,7 @@ export {
1022
1377
  getAzureVoiceCatalogMetadata,
1023
1378
  getBuiltInVoiceCatalogMetadata,
1024
1379
  getSsmlSourceMap,
1380
+ inspectAudioSpecification,
1025
1381
  isValidAzureAudioDuration,
1026
1382
  mapSsmlTextNodes,
1027
1383
  mergeAudioBuffers,
@@ -1037,6 +1393,7 @@ export {
1037
1393
  synthesizeSsmlChunksSafe,
1038
1394
  synthesizeSsmlSafe,
1039
1395
  validateAzureSsml,
1396
+ validateAzureSsmlChunks,
1040
1397
  validateSsml,
1041
1398
  validateSsmlStructureIntegrity
1042
1399
  };