@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.mjs CHANGED
@@ -48,6 +48,14 @@ var MergeError = class extends Error {
48
48
  this.cause = cause;
49
49
  }
50
50
  };
51
+ var AudioFormatMismatchError = class extends Error {
52
+ constructor(message, inputSpecs = []) {
53
+ super(message);
54
+ this.kind = "audio-format-mismatch";
55
+ this.name = "AudioFormatMismatchError";
56
+ this.inputSpecs = inputSpecs;
57
+ }
58
+ };
51
59
  var UnsupportedMergeFormatError = class extends Error {
52
60
  constructor(format) {
53
61
  super(`Audio format "${format}" cannot be safely concatenated; container re-multiplexing is required.`);
@@ -57,7 +65,7 @@ var UnsupportedMergeFormatError = class extends Error {
57
65
  }
58
66
  };
59
67
  function toSynthesisError(error) {
60
- if (error instanceof AzureTtsError || error instanceof MergeError || error instanceof UnsupportedMergeFormatError || error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError)
68
+ if (error instanceof AzureTtsError || error instanceof MergeError || error instanceof AudioFormatMismatchError || error instanceof UnsupportedMergeFormatError || error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError)
61
69
  return error;
62
70
  const message = error instanceof Error ? error.message : String(error);
63
71
  if (/cancel|abort/i.test(message)) return new SynthesisCancelledError(message);
@@ -187,6 +195,105 @@ function parseWav(buffer) {
187
195
  }
188
196
  return { chunks, data, format };
189
197
  }
198
+ function formatNumber(format, pattern, fallback) {
199
+ const match = pattern.exec(format);
200
+ return match?.[1] ? Number(match[1]) : fallback;
201
+ }
202
+ function formatChannels(format, fallback) {
203
+ if (/stereo|2ch|dual/i.test(format)) return 2;
204
+ if (/mono|1ch/i.test(format)) return 1;
205
+ return fallback;
206
+ }
207
+ function formatAudioSpecification(format) {
208
+ const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
209
+ const channels = formatChannels(format, 0);
210
+ const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
211
+ const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1e3 : void 0;
212
+ 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";
213
+ return {
214
+ format,
215
+ mimeType: resolveMimeType(format),
216
+ codec,
217
+ sampleRate,
218
+ channels,
219
+ ...bitrate ? { bitrate } : {},
220
+ isCompressed: codec === "mp3" || codec === "opus" || codec === "silk"
221
+ };
222
+ }
223
+ function parseMp3Specification(buffer, format) {
224
+ const bytes = stripMp3Tags(buffer);
225
+ const bitrates = [
226
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
227
+ [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0],
228
+ [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0]
229
+ ];
230
+ const sampleRates = [
231
+ [44100, 48e3, 32e3],
232
+ [22050, 24e3, 16e3],
233
+ [11025, 12e3, 8e3]
234
+ ];
235
+ for (let index = 0; index + 4 <= bytes.length; index += 1) {
236
+ if (bytes[index] !== 255 || (bytes[index + 1] ?? 0) < 224) continue;
237
+ const header = bytes[index + 1] ?? 0;
238
+ const versionBits = header >> 3 & 3;
239
+ const layer = header >> 1 & 3;
240
+ const bitrateIndex = (bytes[index + 2] ?? 0) >> 4;
241
+ const sampleIndex = (bytes[index + 2] ?? 0) >> 2 & 3;
242
+ if (versionBits === 1 || layer !== 1 || bitrateIndex === 0 || bitrateIndex === 15 || sampleIndex === 3) continue;
243
+ const versionIndex = versionBits === 3 ? 0 : versionBits === 2 ? 1 : 2;
244
+ const bitrateTable = versionBits === 3 ? bitrates[1] : bitrates[2];
245
+ const sampleRate = sampleRates[versionIndex]?.[sampleIndex] ?? 0;
246
+ const bitrateKbps = bitrateTable?.[bitrateIndex] ?? 0;
247
+ if (!sampleRate || !bitrateKbps) continue;
248
+ return {
249
+ format,
250
+ mimeType: "audio/mpeg",
251
+ codec: "mp3",
252
+ sampleRate,
253
+ channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
254
+ bitrate: bitrateKbps * 1e3,
255
+ isCompressed: true
256
+ };
257
+ }
258
+ return void 0;
259
+ }
260
+ function inspectAudioSpecification(buffer, format) {
261
+ if (isWavFormat(format) || ascii(new Uint8Array(buffer), 0, "RIFF")) {
262
+ const parsed = parseWav(buffer);
263
+ if (parsed.format.byteLength < 16) throw new Error("Invalid WAV fmt chunk.");
264
+ const view = new DataView(parsed.format.buffer, parsed.format.byteOffset, parsed.format.byteLength);
265
+ const sampleRate = view.getUint32(4, true);
266
+ const channels = view.getUint16(2, true);
267
+ const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
268
+ const formatCode = view.getUint16(0, true);
269
+ return {
270
+ format,
271
+ mimeType: "audio/wav",
272
+ codec: formatCode === 1 ? "pcm" : "unknown",
273
+ sampleRate,
274
+ channels,
275
+ ...sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {},
276
+ isCompressed: formatCode !== 1
277
+ };
278
+ }
279
+ if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
280
+ return formatAudioSpecification(format);
281
+ }
282
+ function validateAudioSpecifications(specs) {
283
+ const first = specs[0];
284
+ if (!first) return;
285
+ const mismatch = specs.find(
286
+ (spec) => spec.sampleRate !== first.sampleRate || spec.channels !== first.channels || first.bitrate !== void 0 && spec.bitrate !== void 0 && spec.bitrate !== first.bitrate
287
+ );
288
+ if (mismatch)
289
+ throw new AudioFormatMismatchError(
290
+ `Audio chunks have incompatible specifications: ${first.sampleRate}Hz/${first.channels}ch versus ${mismatch.sampleRate}Hz/${mismatch.channels}ch.`,
291
+ specs
292
+ );
293
+ }
294
+ function isAudioFormatMismatch(error) {
295
+ return error instanceof AudioFormatMismatchError || error !== null && typeof error === "object" && "kind" in error && error.kind === "audio-format-mismatch";
296
+ }
190
297
  function writeUint32(target, offset, value) {
191
298
  new DataView(target.buffer).setUint32(offset, value, true);
192
299
  }
@@ -272,6 +379,7 @@ function mergeAudioBuffers(buffers, options) {
272
379
  const format = typeof options === "string" ? options : options?.format;
273
380
  if (!format) throw new UnsupportedMergeFormatError("");
274
381
  try {
382
+ validateAudioSpecifications(buffers.map((buffer) => inspectAudioSpecification(buffer, format)));
275
383
  if (isWavFormat(format)) return mergeWavBuffers(buffers);
276
384
  if (isMp3Format(format)) {
277
385
  const parts = buffers.map(stripMp3Tags);
@@ -294,7 +402,8 @@ function mergeAudioBuffers(buffers, options) {
294
402
  }
295
403
  throw new UnsupportedMergeFormatError(format);
296
404
  } catch (error) {
297
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
405
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
406
+ throw error;
298
407
  throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
299
408
  }
300
409
  }
@@ -372,17 +481,26 @@ async function synthesizeSsml(ssml, config) {
372
481
  return {
373
482
  originalTextRange: { ...marker.originalTextRange },
374
483
  sourceNodePath: [...marker.sourceNodePath],
375
- textRange: { ...marker.originalTextRange }
484
+ textRange: { ...marker.originalTextRange },
485
+ mappingStatus: "exact"
376
486
  };
377
487
  }
378
- if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) return {};
488
+ if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) {
489
+ const unmapped = { mappingStatus: "unmapped" };
490
+ Object.defineProperty(unmapped, "mappingStatus", { value: "unmapped", enumerable: false });
491
+ return unmapped;
492
+ }
379
493
  const value = text ?? "";
380
494
  let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? offsetHint : -1;
381
- if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value)
495
+ let mappingStatus = "exact";
496
+ if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value) {
382
497
  localStart = -1;
498
+ mappingStatus = "fallback";
499
+ }
383
500
  if (localStart < 0 || localStart > sourceText.length) {
384
501
  localStart = value ? sourceText.indexOf(value, sourceEventCursor) : sourceEventCursor;
385
502
  if (localStart < 0) localStart = value ? sourceText.indexOf(value) : sourceEventCursor;
503
+ mappingStatus = "fallback";
386
504
  }
387
505
  localStart = Math.max(0, localStart);
388
506
  const localEnd = Math.min(sourceText.length, localStart + value.length);
@@ -393,7 +511,8 @@ async function synthesizeSsml(ssml, config) {
393
511
  return {
394
512
  originalTextRange: { ...fallbackRange },
395
513
  textRange: { ...fallbackRange },
396
- ...segment ? { sourceNodePath: [...segment.sourceNodePath] } : config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {}
514
+ ...segment ? { sourceNodePath: [...segment.sourceNodePath] } : config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
515
+ mappingStatus: segment || config.sourceTextRange || config.sourceNodePath ? mappingStatus : "unmapped"
397
516
  };
398
517
  };
399
518
  synthesizer.wordBoundary = (_sender, event) => {
@@ -442,20 +561,27 @@ async function synthesizeSsml(ssml, config) {
442
561
  );
443
562
  const durationMs = result.audioDuration ? ticksToMilliseconds(result.audioDuration) : eventDurationMs;
444
563
  const requestId = result.resultId;
445
- const addSourceMetadata = (event) => ({
446
- ...event,
447
- ...config.sourceTextRange && !("textRange" in event) ? { textRange: { ...config.sourceTextRange } } : {},
448
- ...config.sourceTextRange && !("originalTextRange" in event) ? { originalTextRange: { ...config.sourceTextRange } } : {},
449
- ...config.chunkIndex !== void 0 ? { chunkIndex: config.chunkIndex } : {},
450
- ...config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
451
- ...requestId ? { requestId } : {}
452
- });
564
+ const addSourceMetadata = (event) => {
565
+ const mapped = {
566
+ ...event,
567
+ ...config.sourceTextRange && !("textRange" in event) ? { textRange: { ...config.sourceTextRange } } : {},
568
+ ...config.sourceTextRange && !("originalTextRange" in event) ? { originalTextRange: { ...config.sourceTextRange } } : {},
569
+ ...config.chunkIndex !== void 0 ? { chunkIndex: config.chunkIndex } : {},
570
+ ...config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
571
+ ...requestId ? { requestId } : {}
572
+ };
573
+ if (event.mappingStatus === "unmapped")
574
+ Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
575
+ return mapped;
576
+ };
453
577
  const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
454
578
  const sourceVisemes = visemes.map((viseme) => addSourceMetadata(viseme));
455
579
  const sourceBookmarks = bookmarks.map((bookmark) => addSourceMetadata(bookmark));
456
580
  resolve({
457
581
  audioData: result.audioData,
458
582
  durationMs,
583
+ audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
584
+ mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
459
585
  ...config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {},
460
586
  ...requestId ? { requestId } : {},
461
587
  ...sourceBoundaries.length > 0 ? { boundaries: sourceBoundaries, wordBoundary: sourceBoundaries, wordBoundaries: sourceBoundaries } : {},
@@ -480,8 +606,66 @@ async function synthesizeSsml(ssml, config) {
480
606
  }
481
607
  });
482
608
  }
609
+ function isRetryableSynthesisError(error) {
610
+ if (error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError) return false;
611
+ if (error instanceof AzureTtsError && error.status !== 0)
612
+ return error.status === 429 || error.status >= 500 && error.status < 600;
613
+ const message = error instanceof Error ? error.message : String(error);
614
+ if (/\b4\d{2}\b/.test(message)) return false;
615
+ const status = error && typeof error === "object" && "status" in error ? error.status : void 0;
616
+ if (typeof status === "number") return status === 429 || status >= 500 && status < 600;
617
+ return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
618
+ }
619
+ function retryDelay(options, retryAttempt) {
620
+ const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
621
+ return Math.floor(Math.random() * (base + 1));
622
+ }
623
+ function resolveConcurrency(value, total) {
624
+ if (value === void 0) return 1;
625
+ if (value === Infinity) return Math.max(1, total);
626
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
627
+ }
628
+ async function waitForRetry(delayMs, signal) {
629
+ if (signal?.aborted) throw new SynthesisCancelledError();
630
+ if (delayMs <= 0) return;
631
+ await new Promise((resolve, reject) => {
632
+ let timer;
633
+ const abort = () => {
634
+ clearTimeout(timer);
635
+ signal?.removeEventListener("abort", abort);
636
+ reject(new SynthesisCancelledError());
637
+ };
638
+ timer = setTimeout(() => {
639
+ signal?.removeEventListener("abort", abort);
640
+ resolve();
641
+ }, delayMs);
642
+ if (signal) {
643
+ signal.addEventListener("abort", abort, { once: true });
644
+ }
645
+ });
646
+ }
647
+ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
648
+ const options = retryOptions ? {
649
+ maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
650
+ initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
651
+ maxDelayMs: Math.max(0, retryOptions.maxDelayMs)
652
+ } : void 0;
653
+ let attempt = 0;
654
+ while (true) {
655
+ if (config.signal?.aborted) throw new SynthesisCancelledError();
656
+ try {
657
+ return await synthesizeSsml(ssml, config);
658
+ } catch (error) {
659
+ if (!options || attempt >= options.maxRetries || !isRetryableSynthesisError(error)) throw error;
660
+ attempt += 1;
661
+ const delayMs = retryDelay(options, attempt);
662
+ onRetry(attempt, delayMs);
663
+ await waitForRetry(delayMs, config.signal);
664
+ }
665
+ }
666
+ }
483
667
  async function synthesizeSsmlChunks(chunks, config) {
484
- const results = [];
668
+ const results = new Array(chunks.length);
485
669
  const totalChunks = chunks.length;
486
670
  const report = (event) => config.onProgress?.(event);
487
671
  for (const [index, chunk] of chunks.entries()) {
@@ -496,57 +680,85 @@ async function synthesizeSsmlChunks(chunks, config) {
496
680
  durationMs: 0
497
681
  });
498
682
  }
499
- for (const [index, chunk] of chunks.entries()) {
500
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
501
- report({
502
- currentChunk: index,
503
- totalChunks,
504
- percent: totalChunks === 0 ? 100 : Math.round(index / totalChunks * 100),
505
- chunkIndex: index,
506
- originalTextRange: input.originalTextRange,
507
- status: "synthesizing",
508
- durationMs: 0
509
- });
510
- const startedAt = Date.now();
511
- try {
512
- const result = await synthesizeSsml(input.ssml, {
513
- ...config,
514
- ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
515
- ...input.sourceNodePath ?? config.sourceNodePath ? { sourceNodePath: [...input.sourceNodePath ?? config.sourceNodePath ?? []] } : {},
516
- ...input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {},
517
- ...input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {},
518
- chunkIndex: index,
519
- onProgress: void 0
520
- });
521
- results.push(result);
522
- report({
523
- currentChunk: index + 1,
524
- totalChunks,
525
- percent: totalChunks === 0 ? 100 : Math.round((index + 1) / totalChunks * 100),
526
- chunkIndex: index,
527
- originalTextRange: input.originalTextRange,
528
- status: "success",
529
- durationMs: Date.now() - startedAt
530
- });
531
- } catch (error) {
683
+ let completed = 0;
684
+ let nextIndex = 0;
685
+ const concurrency = resolveConcurrency(config.concurrency, chunks.length);
686
+ const worker = async () => {
687
+ while (true) {
688
+ const index = nextIndex++;
689
+ if (index >= chunks.length) return;
690
+ const chunk = chunks[index];
691
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
532
692
  report({
533
- currentChunk: index,
693
+ currentChunk: completed,
534
694
  totalChunks,
535
- percent: totalChunks === 0 ? 100 : Math.round(index / totalChunks * 100),
695
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
536
696
  chunkIndex: index,
537
697
  originalTextRange: input.originalTextRange,
538
- status: "failed",
539
- durationMs: Date.now() - startedAt,
540
- error
698
+ status: "synthesizing",
699
+ durationMs: 0
541
700
  });
542
- throw error;
701
+ const startedAt = Date.now();
702
+ try {
703
+ const result = await synthesizeWithRetry(
704
+ input.ssml,
705
+ {
706
+ ...config,
707
+ ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
708
+ ...input.sourceNodePath ?? config.sourceNodePath ? { sourceNodePath: [...input.sourceNodePath ?? config.sourceNodePath ?? []] } : {},
709
+ ...input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {},
710
+ ...input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {},
711
+ chunkIndex: index,
712
+ onProgress: void 0
713
+ },
714
+ config.retryOptions,
715
+ (retryAttempt, nextRetryDelayMs) => report({
716
+ currentChunk: completed,
717
+ totalChunks,
718
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
719
+ chunkIndex: index,
720
+ originalTextRange: input.originalTextRange,
721
+ status: "synthesizing",
722
+ durationMs: Date.now() - startedAt,
723
+ retryAttempt,
724
+ nextRetryDelayMs,
725
+ isRetrying: true
726
+ })
727
+ );
728
+ results[index] = result;
729
+ completed += 1;
730
+ report({
731
+ currentChunk: completed,
732
+ totalChunks,
733
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
734
+ chunkIndex: index,
735
+ originalTextRange: input.originalTextRange,
736
+ status: "success",
737
+ durationMs: Date.now() - startedAt
738
+ });
739
+ } catch (error) {
740
+ report({
741
+ currentChunk: completed,
742
+ totalChunks,
743
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
744
+ chunkIndex: index,
745
+ originalTextRange: input.originalTextRange,
746
+ status: "failed",
747
+ durationMs: Date.now() - startedAt,
748
+ error
749
+ });
750
+ throw error;
751
+ }
543
752
  }
544
- }
545
- return mergeSynthesisResults(results, {
546
- format: config.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3"
753
+ };
754
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
755
+ const orderedResults = results.filter((result) => result !== void 0);
756
+ return mergeSynthesisResults(orderedResults, {
757
+ format: config.outputFormat ?? DEFAULT_OUTPUT_FORMAT,
758
+ signal: config.signal
547
759
  });
548
760
  }
549
- function createMergedResult(results, audioData, format) {
761
+ function createMergedResult(results, audioData, format, audioSpec, outputMimeType) {
550
762
  const boundaries = [];
551
763
  const visemes = [];
552
764
  const bookmarks = [];
@@ -565,7 +777,8 @@ function createMergedResult(results, audioData, format) {
565
777
  ...boundary.sourceNodePath ? { sourceNodePath: [...boundary.sourceNodePath] } : {},
566
778
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
567
779
  ...textRange ? { textRange: { ...textRange } } : {},
568
- ...requestId ? { requestId } : {}
780
+ ...requestId ? { requestId } : {},
781
+ mappingStatus: boundary.mappingStatus ?? "unmapped"
569
782
  });
570
783
  }
571
784
  for (const viseme of result.visemes ?? []) {
@@ -580,7 +793,8 @@ function createMergedResult(results, audioData, format) {
580
793
  ...viseme.sourceNodePath ? { sourceNodePath: [...viseme.sourceNodePath] } : {},
581
794
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
582
795
  ...textRange ? { textRange: { ...textRange } } : {},
583
- ...requestId ? { requestId } : {}
796
+ ...requestId ? { requestId } : {},
797
+ mappingStatus: viseme.mappingStatus ?? "unmapped"
584
798
  });
585
799
  }
586
800
  for (const bookmark of result.bookmarks ?? []) {
@@ -595,7 +809,8 @@ function createMergedResult(results, audioData, format) {
595
809
  ...bookmark.sourceNodePath ? { sourceNodePath: [...bookmark.sourceNodePath] } : {},
596
810
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
597
811
  ...textRange ? { textRange: { ...textRange } } : {},
598
- ...requestId ? { requestId } : {}
812
+ ...requestId ? { requestId } : {},
813
+ mappingStatus: bookmark.mappingStatus ?? "unmapped"
599
814
  });
600
815
  }
601
816
  durationOffset += Math.max(0, result.durationMs);
@@ -604,6 +819,8 @@ function createMergedResult(results, audioData, format) {
604
819
  audioData,
605
820
  durationMs: durationOffset,
606
821
  mimeType: resolveMimeType(format),
822
+ audioSpec: audioSpec ?? formatAudioSpecification(format),
823
+ ...outputMimeType ? { mimeType: outputMimeType } : {},
607
824
  ...boundaries.length > 0 ? { boundaries, wordBoundary: boundaries, wordBoundaries: boundaries } : {},
608
825
  ...visemes.length > 0 ? { visemes } : {},
609
826
  ...bookmarks.length > 0 ? { bookmarks } : {},
@@ -616,19 +833,47 @@ function mergeSynthesisResults(results, options) {
616
833
  const format = resolvedOptions?.format;
617
834
  if (!format) throw new UnsupportedMergeFormatError("");
618
835
  const buffers = results.map((result) => result.audioData);
836
+ const inputSpecs = results.map((result) => result.audioSpec ?? inspectAudioSpecification(result.audioData, format));
837
+ validateAudioSpecifications(inputSpecs);
838
+ const signal = resolvedOptions.signal ?? new AbortController().signal;
839
+ if (signal.aborted) throw new SynthesisCancelledError();
619
840
  if (resolvedOptions.customMerger) {
620
- return Promise.resolve().then(() => resolvedOptions.customMerger?.(buffers, format)).then((merged) => {
841
+ return Promise.resolve().then(
842
+ () => resolvedOptions.customMerger?.(buffers, {
843
+ format,
844
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
845
+ inputSpecs,
846
+ signal
847
+ })
848
+ ).then((merged) => {
621
849
  if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
622
- return createMergedResult(results, merged, format);
850
+ if (!(merged instanceof ArrayBuffer) || buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
851
+ throw new MergeError("The custom audio merger returned an invalid audio buffer.");
852
+ if (signal.aborted) throw new SynthesisCancelledError();
853
+ return createMergedResult(
854
+ results,
855
+ merged,
856
+ format,
857
+ inspectAudioSpecification(merged, format),
858
+ resolvedOptions.outputMimeType
859
+ );
623
860
  }).catch((error) => {
624
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
861
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
862
+ throw error;
625
863
  throw new MergeError(`Custom audio merger failed for format "${format}".`, error);
626
864
  });
627
865
  }
628
866
  try {
629
- return createMergedResult(results, mergeAudioBuffers(buffers, { format }), format);
867
+ return createMergedResult(
868
+ results,
869
+ mergeAudioBuffers(buffers, { format }),
870
+ format,
871
+ inputSpecs[0],
872
+ resolvedOptions.outputMimeType
873
+ );
630
874
  } catch (error) {
631
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
875
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
876
+ throw error;
632
877
  throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
633
878
  }
634
879
  }
@@ -637,7 +882,10 @@ async function synthesizeSpeech(ssml, config) {
637
882
  }
638
883
 
639
884
  // src/safe.ts
640
- import { validateAzureSsml } from "@ssml-builder-js/ssml-core";
885
+ import {
886
+ createAzureUrlValidatorRunner,
887
+ validateAzureSsml
888
+ } from "@ssml-builder-js/ssml-core";
641
889
  var ChunkValidationError = class extends Error {
642
890
  constructor(chunkIndex, diagnostics) {
643
891
  super(`SSML validation failed for chunk ${chunkIndex}; the Azure Speech API was not called.`);
@@ -650,8 +898,73 @@ var ChunkValidationError = class extends Error {
650
898
  function failure(error) {
651
899
  return { ok: false, success: false, status: error.kind, error };
652
900
  }
901
+ function isRetryable(error) {
902
+ if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
903
+ const status = error && typeof error === "object" && "status" in error ? error.status : void 0;
904
+ if (typeof status === "number" && status !== 0) return status === 429 || status >= 500 && status < 600;
905
+ const message = error instanceof Error ? error.message : String(error);
906
+ if (/\b4\d{2}\b/.test(message)) return false;
907
+ return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
908
+ }
909
+ function delayForRetry(options, attempt) {
910
+ const maxDelay = Math.max(0, options.maxDelayMs);
911
+ const base = Math.min(maxDelay, Math.max(0, options.initialDelayMs) * 2 ** Math.max(0, attempt - 1));
912
+ return Math.floor(Math.random() * (base + 1));
913
+ }
914
+ function resolveConcurrency2(value, total) {
915
+ if (value === void 0) return 1;
916
+ if (value === Infinity) return Math.max(1, total);
917
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
918
+ }
919
+ async function retryableSynthesis(synthesize, options, signal, onRetry) {
920
+ const retry = options ? {
921
+ maxRetries: Math.max(0, Math.floor(options.maxRetries)),
922
+ initialDelayMs: options.initialDelayMs,
923
+ maxDelayMs: options.maxDelayMs
924
+ } : void 0;
925
+ let attempt = 0;
926
+ while (true) {
927
+ if (signal?.aborted) throw new Error("Speech synthesis was cancelled.");
928
+ try {
929
+ return await synthesize();
930
+ } catch (error) {
931
+ if (!retry || attempt >= retry.maxRetries || !isRetryable(error)) throw error;
932
+ attempt += 1;
933
+ const delayMs = delayForRetry(retry, attempt);
934
+ onRetry(attempt, delayMs);
935
+ if (delayMs > 0)
936
+ await new Promise((resolve, reject) => {
937
+ const timer = setTimeout(() => {
938
+ signal?.removeEventListener("abort", abort);
939
+ resolve();
940
+ }, delayMs);
941
+ const abort = () => {
942
+ clearTimeout(timer);
943
+ signal?.removeEventListener("abort", abort);
944
+ reject(new Error("Speech synthesis was cancelled."));
945
+ };
946
+ signal?.addEventListener("abort", abort, { once: true });
947
+ });
948
+ }
949
+ }
950
+ }
951
+ function sharedValidationOptions(options, signal) {
952
+ const validator = options.urlValidator ?? options.customUrlValidator;
953
+ if (!validator) return signal ? withValidationSignal(options, signal) : options;
954
+ const runner = createAzureUrlValidatorRunner(validator, {
955
+ ...options.urlValidation ?? {},
956
+ ...options.urlValidatorConcurrency !== void 0 ? { concurrency: options.urlValidatorConcurrency } : {},
957
+ ...options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
958
+ ...signal ? { signal } : {},
959
+ ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
960
+ });
961
+ return {
962
+ ...withValidationSignal(options, signal),
963
+ urlValidatorRunner: runner
964
+ };
965
+ }
653
966
  async function synthesizeSsmlSafe(client, ssml, options = {}) {
654
- const validationOptions = withValidationSignal(options.validation ?? options, options.signal);
967
+ const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
655
968
  const diagnostics = await Promise.resolve(validateAzureSsml(ssml, validationOptions));
656
969
  if (options.signal?.aborted) {
657
970
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
@@ -678,7 +991,7 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
678
991
  }
679
992
  }
680
993
  async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
681
- const validationOptions = withValidationSignal(options.validation ?? options, options.signal);
994
+ const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
682
995
  if (options.signal?.aborted) {
683
996
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
684
997
  return failure(error);
@@ -699,16 +1012,24 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
699
1012
  pending(index, "pending");
700
1013
  });
701
1014
  const validations = await Promise.all(
702
- chunks.map(async (chunk) => {
1015
+ chunks.map(async (chunk, index) => {
703
1016
  const ssml = typeof chunk === "string" ? chunk : chunk.ssml;
704
1017
  const sourceNodePath = typeof chunk === "string" ? options.sourceNodePath : chunk.sourceNodePath ?? options.sourceNodePath;
705
1018
  const diagnostics = await Promise.resolve(
706
- validateAzureSsml(ssml, { ...validationOptions, ...sourceNodePath ? { sourceNodePath } : {} })
1019
+ validateAzureSsml(ssml, {
1020
+ ...validationOptions,
1021
+ ...sourceNodePath ? { sourceNodePath } : {},
1022
+ chunkIndex: index
1023
+ })
707
1024
  );
708
1025
  return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
709
1026
  })
710
1027
  );
711
1028
  const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
1029
+ if (options.signal?.aborted) {
1030
+ const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
1031
+ return failure(error);
1032
+ }
712
1033
  if (firstInvalidIndex >= 0) {
713
1034
  const error = new ChunkValidationError(firstInvalidIndex, validations[firstInvalidIndex] ?? []);
714
1035
  pending(firstInvalidIndex, "failed", error);
@@ -725,96 +1046,126 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
725
1046
  outputFormat: options.outputFormat,
726
1047
  signal: options.signal,
727
1048
  timeoutMs: options.timeoutMs,
728
- sourceNodePath: options.sourceNodePath
1049
+ sourceNodePath: options.sourceNodePath,
1050
+ concurrency: options.concurrency,
1051
+ retryOptions: options.retryOptions
729
1052
  });
730
1053
  return { ok: true, success: true, status: "success", value };
731
1054
  }
732
- const results = [];
733
- for (const [index, chunk] of chunks.entries()) {
734
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
735
- const sourceNodePath = input.sourceNodePath;
736
- const originalTextRange = input.originalTextRange;
737
- pending(index, "synthesizing");
738
- const startedAt = Date.now();
739
- try {
740
- const result = await client.synthesizeSsml(input.ssml, {
741
- outputFormat: options.outputFormat,
742
- signal: options.signal,
743
- timeoutMs: options.timeoutMs,
744
- sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
745
- });
746
- results.push({
747
- ...result,
748
- ...input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {},
749
- ...sourceNodePath ? {
750
- boundaries: result.boundaries?.map((event) => ({
751
- ...event,
752
- sourceNodePath: [...sourceNodePath],
753
- ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
754
- })),
755
- visemes: result.visemes?.map((event) => ({
756
- ...event,
757
- sourceNodePath: [...sourceNodePath],
758
- ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
759
- })),
760
- bookmarks: result.bookmarks?.map((event) => ({
761
- ...event,
762
- sourceNodePath: [...sourceNodePath],
763
- ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
764
- }))
765
- } : {},
766
- ...originalTextRange ? {
767
- boundaries: result.boundaries?.map((event) => ({
768
- ...event,
769
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
770
- })),
771
- wordBoundary: result.wordBoundary?.map((event) => ({
772
- ...event,
773
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
774
- })),
775
- wordBoundaries: result.wordBoundaries?.map((event) => ({
776
- ...event,
777
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
778
- })),
779
- visemes: result.visemes?.map((event) => ({
780
- ...event,
781
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
782
- })),
783
- bookmarks: result.bookmarks?.map((event) => ({
784
- ...event,
785
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
786
- }))
787
- } : {}
788
- });
789
- options.onProgress?.({
790
- currentChunk: index + 1,
791
- totalChunks: chunks.length,
792
- percent: chunks.length === 0 ? 100 : Math.round((index + 1) / chunks.length * 100),
793
- chunkIndex: index,
794
- originalTextRange: input.originalTextRange,
795
- status: "success",
796
- durationMs: Date.now() - startedAt
797
- });
798
- } catch (error) {
799
- options.onProgress?.({
800
- currentChunk: index,
801
- totalChunks: chunks.length,
802
- percent: chunks.length === 0 ? 100 : Math.round(index / chunks.length * 100),
803
- chunkIndex: index,
804
- originalTextRange: input.originalTextRange,
805
- status: "failed",
806
- durationMs: Date.now() - startedAt,
807
- error
808
- });
809
- throw error;
1055
+ const results = new Array(chunks.length);
1056
+ let completed = 0;
1057
+ let nextIndex = 0;
1058
+ const concurrency = resolveConcurrency2(options.concurrency, chunks.length);
1059
+ const worker = async () => {
1060
+ while (true) {
1061
+ const index = nextIndex++;
1062
+ if (index >= chunks.length) return;
1063
+ const chunk = chunks[index];
1064
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
1065
+ const sourceNodePath = input.sourceNodePath;
1066
+ const originalTextRange = input.originalTextRange;
1067
+ pending(index, "synthesizing");
1068
+ const startedAt = Date.now();
1069
+ try {
1070
+ const result = await retryableSynthesis(
1071
+ () => client.synthesizeSsml(input.ssml, {
1072
+ outputFormat: options.outputFormat,
1073
+ signal: options.signal,
1074
+ timeoutMs: options.timeoutMs,
1075
+ sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
1076
+ }),
1077
+ options.retryOptions,
1078
+ options.signal,
1079
+ (retryAttempt, nextRetryDelayMs) => options.onProgress?.({
1080
+ currentChunk: completed,
1081
+ totalChunks: chunks.length,
1082
+ percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1083
+ chunkIndex: index,
1084
+ originalTextRange: input.originalTextRange,
1085
+ status: "synthesizing",
1086
+ durationMs: Date.now() - startedAt,
1087
+ retryAttempt,
1088
+ nextRetryDelayMs,
1089
+ isRetrying: true
1090
+ })
1091
+ );
1092
+ results[index] = {
1093
+ ...result,
1094
+ ...input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {},
1095
+ ...sourceNodePath ? {
1096
+ boundaries: result.boundaries?.map((event) => ({
1097
+ ...event,
1098
+ sourceNodePath: [...sourceNodePath],
1099
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1100
+ })),
1101
+ visemes: result.visemes?.map((event) => ({
1102
+ ...event,
1103
+ sourceNodePath: [...sourceNodePath],
1104
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1105
+ })),
1106
+ bookmarks: result.bookmarks?.map((event) => ({
1107
+ ...event,
1108
+ sourceNodePath: [...sourceNodePath],
1109
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1110
+ }))
1111
+ } : {},
1112
+ ...originalTextRange ? {
1113
+ boundaries: result.boundaries?.map((event) => ({
1114
+ ...event,
1115
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1116
+ })),
1117
+ wordBoundary: result.wordBoundary?.map((event) => ({
1118
+ ...event,
1119
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1120
+ })),
1121
+ wordBoundaries: result.wordBoundaries?.map((event) => ({
1122
+ ...event,
1123
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1124
+ })),
1125
+ visemes: result.visemes?.map((event) => ({
1126
+ ...event,
1127
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1128
+ })),
1129
+ bookmarks: result.bookmarks?.map((event) => ({
1130
+ ...event,
1131
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1132
+ }))
1133
+ } : {}
1134
+ };
1135
+ completed += 1;
1136
+ options.onProgress?.({
1137
+ currentChunk: completed,
1138
+ totalChunks: chunks.length,
1139
+ percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1140
+ chunkIndex: index,
1141
+ originalTextRange: input.originalTextRange,
1142
+ status: "success",
1143
+ durationMs: Date.now() - startedAt
1144
+ });
1145
+ } catch (error) {
1146
+ options.onProgress?.({
1147
+ currentChunk: completed,
1148
+ totalChunks: chunks.length,
1149
+ percent: chunks.length === 0 ? 100 : Math.round(index / chunks.length * 100),
1150
+ chunkIndex: index,
1151
+ originalTextRange: input.originalTextRange,
1152
+ status: "failed",
1153
+ durationMs: Date.now() - startedAt,
1154
+ error
1155
+ });
1156
+ throw error;
1157
+ }
810
1158
  }
811
- }
1159
+ };
1160
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
1161
+ const orderedResults = results.filter((result) => result !== void 0);
812
1162
  return {
813
1163
  ok: true,
814
1164
  success: true,
815
1165
  status: "success",
816
- value: mergeSynthesisResults(results, {
817
- format: options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3"
1166
+ value: mergeSynthesisResults(orderedResults, {
1167
+ format: options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3",
1168
+ signal: options.signal
818
1169
  })
819
1170
  };
820
1171
  } catch (error) {
@@ -873,7 +1224,9 @@ var AzureTtsClient = class {
873
1224
  signal: options.signal ?? signal,
874
1225
  timeoutMs: options.timeoutMs ?? timeoutMs,
875
1226
  sourceNodePath: options.sourceNodePath,
876
- onProgress: options.onProgress ?? __privateGet(this, _options).onProgress
1227
+ onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1228
+ concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1229
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
877
1230
  });
878
1231
  }
879
1232
  async synthesizeSsmlSafe(ssml, options = {}) {
@@ -885,7 +1238,9 @@ var AzureTtsClient = class {
885
1238
  outputFormat: options.outputFormat ?? __privateGet(this, _options).outputFormat,
886
1239
  signal: options.signal ?? __privateGet(this, _options).signal,
887
1240
  timeoutMs: options.timeoutMs ?? __privateGet(this, _options).timeoutMs,
888
- onProgress: options.onProgress ?? __privateGet(this, _options).onProgress
1241
+ onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1242
+ concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1243
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
889
1244
  });
890
1245
  }
891
1246
  async synthesizeSsmlChunksSafe(chunks, options = {}) {
@@ -981,6 +1336,7 @@ async function fetchAzureVoiceCatalog(options) {
981
1336
  };
982
1337
  }
983
1338
  export {
1339
+ AudioFormatMismatchError,
984
1340
  AzureTtsClient,
985
1341
  AzureTtsError,
986
1342
  AzureTtsSdkError,
@@ -992,6 +1348,7 @@ export {
992
1348
  UnsupportedMergeFormatError,
993
1349
  canMergeAudioFormat,
994
1350
  fetchAzureVoiceCatalog,
1351
+ inspectAudioSpecification,
995
1352
  mergeAudioBuffers,
996
1353
  mergeSynthesisResults,
997
1354
  resolveMergeAudioFormat,