@ssml-builder-js/azure-tts-client 2.15.0 → 2.17.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,9 +37,11 @@ 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,
44
+ BatchChunkValidationError: () => BatchChunkValidationError,
43
45
  ChunkValidationError: () => ChunkValidationError,
44
46
  DEFAULT_OUTPUT_FORMAT: () => DEFAULT_OUTPUT_FORMAT,
45
47
  MergeError: () => MergeError,
@@ -48,6 +50,8 @@ __export(index_exports, {
48
50
  UnsupportedMergeFormatError: () => UnsupportedMergeFormatError,
49
51
  canMergeAudioFormat: () => canMergeAudioFormat,
50
52
  fetchAzureVoiceCatalog: () => fetchAzureVoiceCatalog,
53
+ getRetryAfterDelayMs: () => getRetryAfterDelayMs,
54
+ inspectAudioSpecification: () => inspectAudioSpecification,
51
55
  mergeAudioBuffers: () => mergeAudioBuffers,
52
56
  mergeSynthesisResults: () => mergeSynthesisResults,
53
57
  resolveMergeAudioFormat: () => resolveMergeAudioFormat,
@@ -62,7 +66,7 @@ module.exports = __toCommonJS(index_exports);
62
66
 
63
67
  // src/errors.ts
64
68
  var AzureTtsError = class extends Error {
65
- constructor(status, statusText, responseBody, requestId) {
69
+ constructor(status, statusText, responseBody, requestId, responseHeaders) {
66
70
  super(`Azure TTS request failed: ${status} ${statusText}`);
67
71
  this.kind = "azure-api-error";
68
72
  this.name = "AzureTtsError";
@@ -70,8 +74,37 @@ var AzureTtsError = class extends Error {
70
74
  this.statusText = statusText;
71
75
  this.responseBody = responseBody;
72
76
  this.requestId = requestId;
77
+ const value = responseHeaders instanceof Headers ? responseHeaders.get("retry-after") : responseHeaders?.["retry-after"] ?? responseHeaders?.["Retry-After"];
78
+ const seconds = value ? Number(value.trim()) : NaN;
79
+ const date = value ? Date.parse(value) : NaN;
80
+ if (Number.isFinite(seconds) && seconds >= 0) this.retryAfterMs = seconds * 1e3;
81
+ else if (Number.isFinite(date)) this.retryAfterMs = Math.max(0, date - Date.now());
73
82
  }
74
83
  };
84
+ function getRetryAfterDelayMs(error) {
85
+ if (error instanceof AzureTtsError && error.retryAfterMs !== void 0) return error.retryAfterMs;
86
+ if (!error || typeof error !== "object") return void 0;
87
+ const candidate = error;
88
+ if (typeof candidate.retryAfterMs === "number" && candidate.retryAfterMs >= 0) return candidate.retryAfterMs;
89
+ const headers = candidate.headers ?? candidate.response?.headers;
90
+ if (headers instanceof Headers) {
91
+ const value = headers.get("retry-after");
92
+ if (!value) return void 0;
93
+ const seconds = Number(value.trim());
94
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
95
+ const date = Date.parse(value);
96
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : void 0;
97
+ }
98
+ if (headers && typeof headers === "object") {
99
+ const value = headers["retry-after"] ?? headers["Retry-After"];
100
+ if (typeof value !== "string") return void 0;
101
+ const seconds = Number(value.trim());
102
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
103
+ const date = Date.parse(value);
104
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : void 0;
105
+ }
106
+ return void 0;
107
+ }
75
108
  var AzureTtsSdkError = class extends AzureTtsError {
76
109
  constructor(errorDetails) {
77
110
  super(0, "Speech SDK", errorDetails, null);
@@ -102,6 +135,14 @@ var MergeError = class extends Error {
102
135
  this.cause = cause;
103
136
  }
104
137
  };
138
+ var AudioFormatMismatchError = class extends Error {
139
+ constructor(message, inputSpecs = []) {
140
+ super(message);
141
+ this.kind = "audio-format-mismatch";
142
+ this.name = "AudioFormatMismatchError";
143
+ this.inputSpecs = inputSpecs;
144
+ }
145
+ };
105
146
  var UnsupportedMergeFormatError = class extends Error {
106
147
  constructor(format) {
107
148
  super(`Audio format "${format}" cannot be safely concatenated; container re-multiplexing is required.`);
@@ -111,7 +152,7 @@ var UnsupportedMergeFormatError = class extends Error {
111
152
  }
112
153
  };
113
154
  function toSynthesisError(error) {
114
- if (error instanceof AzureTtsError || error instanceof MergeError || error instanceof UnsupportedMergeFormatError || error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError)
155
+ if (error instanceof AzureTtsError || error instanceof MergeError || error instanceof AudioFormatMismatchError || error instanceof UnsupportedMergeFormatError || error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError)
115
156
  return error;
116
157
  const message = error instanceof Error ? error.message : String(error);
117
158
  if (/cancel|abort/i.test(message)) return new SynthesisCancelledError(message);
@@ -241,6 +282,115 @@ function parseWav(buffer) {
241
282
  }
242
283
  return { chunks, data, format };
243
284
  }
285
+ function formatNumber(format, pattern, fallback) {
286
+ const match = pattern.exec(format);
287
+ return match?.[1] ? Number(match[1]) : fallback;
288
+ }
289
+ function formatChannels(format, fallback) {
290
+ if (/stereo|2ch|dual/i.test(format)) return 2;
291
+ if (/mono|1ch/i.test(format)) return 1;
292
+ return fallback;
293
+ }
294
+ function formatAudioSpecification(format) {
295
+ const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
296
+ const channels = formatChannels(format, 0);
297
+ const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
298
+ const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1e3 : void 0;
299
+ 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";
300
+ const bitDepthMatch = /(\d+)bit/i.exec(format);
301
+ const container = /(?:wav|wave|riff)/i.test(format) ? "riff-wave" : /mp3|mpeg/i.test(format) ? "mp3-raw" : /ogg/i.test(format) ? "ogg" : /webm/i.test(format) ? "webm" : /raw/i.test(format) ? "raw" : void 0;
302
+ return {
303
+ format,
304
+ mimeType: resolveMimeType(format),
305
+ codec,
306
+ sampleRate,
307
+ channels,
308
+ ...bitrate ? { bitrate } : {},
309
+ ...bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {},
310
+ ...container ? { container } : {},
311
+ isVbr: /vbr/i.test(format),
312
+ isCompressed: codec === "mp3" || codec === "opus" || codec === "silk"
313
+ };
314
+ }
315
+ function parseMp3Specification(buffer, format) {
316
+ const bytes = stripMp3Tags(buffer);
317
+ const bitrates = [
318
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
319
+ [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0],
320
+ [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0]
321
+ ];
322
+ const sampleRates = [
323
+ [44100, 48e3, 32e3],
324
+ [22050, 24e3, 16e3],
325
+ [11025, 12e3, 8e3]
326
+ ];
327
+ for (let index = 0; index + 4 <= bytes.length; index += 1) {
328
+ if (bytes[index] !== 255 || (bytes[index + 1] ?? 0) < 224) continue;
329
+ const header = bytes[index + 1] ?? 0;
330
+ const versionBits = header >> 3 & 3;
331
+ const layer = header >> 1 & 3;
332
+ const bitrateIndex = (bytes[index + 2] ?? 0) >> 4;
333
+ const sampleIndex = (bytes[index + 2] ?? 0) >> 2 & 3;
334
+ if (versionBits === 1 || layer !== 1 || bitrateIndex === 0 || bitrateIndex === 15 || sampleIndex === 3) continue;
335
+ const versionIndex = versionBits === 3 ? 0 : versionBits === 2 ? 1 : 2;
336
+ const bitrateTable = versionBits === 3 ? bitrates[1] : bitrates[2];
337
+ const sampleRate = sampleRates[versionIndex]?.[sampleIndex] ?? 0;
338
+ const bitrateKbps = bitrateTable?.[bitrateIndex] ?? 0;
339
+ if (!sampleRate || !bitrateKbps) continue;
340
+ return {
341
+ format,
342
+ mimeType: "audio/mpeg",
343
+ codec: "mp3",
344
+ sampleRate,
345
+ channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
346
+ bitrate: bitrateKbps * 1e3,
347
+ container: "mp3-raw",
348
+ isVbr: false,
349
+ isCompressed: true
350
+ };
351
+ }
352
+ return void 0;
353
+ }
354
+ function inspectAudioSpecification(buffer, format) {
355
+ if (isWavFormat(format) || ascii(new Uint8Array(buffer), 0, "RIFF")) {
356
+ const parsed = parseWav(buffer);
357
+ if (parsed.format.byteLength < 16) throw new Error("Invalid WAV fmt chunk.");
358
+ const view = new DataView(parsed.format.buffer, parsed.format.byteOffset, parsed.format.byteLength);
359
+ const sampleRate = view.getUint32(4, true);
360
+ const channels = view.getUint16(2, true);
361
+ const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
362
+ const formatCode = view.getUint16(0, true);
363
+ return {
364
+ format,
365
+ mimeType: "audio/wav",
366
+ codec: formatCode === 1 ? "pcm" : "unknown",
367
+ sampleRate,
368
+ channels,
369
+ ...sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {},
370
+ bitDepth: bitsPerSample,
371
+ container: "riff-wave",
372
+ isVbr: false,
373
+ isCompressed: formatCode !== 1
374
+ };
375
+ }
376
+ if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
377
+ return formatAudioSpecification(format);
378
+ }
379
+ function validateAudioSpecifications(specs) {
380
+ const first = specs[0];
381
+ if (!first) return;
382
+ const mismatch = specs.find(
383
+ (spec) => spec.sampleRate !== first.sampleRate || spec.channels !== first.channels || first.bitrate !== void 0 && spec.bitrate !== void 0 && spec.bitrate !== first.bitrate || first.bitDepth !== void 0 && spec.bitDepth !== void 0 && spec.bitDepth !== first.bitDepth || first.container !== void 0 && spec.container !== void 0 && spec.container !== first.container || first.isVbr !== void 0 && spec.isVbr !== void 0 && spec.isVbr !== first.isVbr
384
+ );
385
+ if (mismatch)
386
+ throw new AudioFormatMismatchError(
387
+ `Audio chunks have incompatible specifications: ${first.sampleRate}Hz/${first.channels}ch versus ${mismatch.sampleRate}Hz/${mismatch.channels}ch.`,
388
+ specs
389
+ );
390
+ }
391
+ function isAudioFormatMismatch(error) {
392
+ return error instanceof AudioFormatMismatchError || error !== null && typeof error === "object" && "kind" in error && error.kind === "audio-format-mismatch";
393
+ }
244
394
  function writeUint32(target, offset, value) {
245
395
  new DataView(target.buffer).setUint32(offset, value, true);
246
396
  }
@@ -326,6 +476,7 @@ function mergeAudioBuffers(buffers, options) {
326
476
  const format = typeof options === "string" ? options : options?.format;
327
477
  if (!format) throw new UnsupportedMergeFormatError("");
328
478
  try {
479
+ validateAudioSpecifications(buffers.map((buffer) => inspectAudioSpecification(buffer, format)));
329
480
  if (isWavFormat(format)) return mergeWavBuffers(buffers);
330
481
  if (isMp3Format(format)) {
331
482
  const parts = buffers.map(stripMp3Tags);
@@ -348,7 +499,8 @@ function mergeAudioBuffers(buffers, options) {
348
499
  }
349
500
  throw new UnsupportedMergeFormatError(format);
350
501
  } catch (error) {
351
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
502
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
503
+ throw error;
352
504
  throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
353
505
  }
354
506
  }
@@ -426,17 +578,24 @@ async function synthesizeSsml(ssml, config) {
426
578
  return {
427
579
  originalTextRange: { ...marker.originalTextRange },
428
580
  sourceNodePath: [...marker.sourceNodePath],
429
- textRange: { ...marker.originalTextRange }
581
+ textRange: { ...marker.originalTextRange },
582
+ mappingStatus: "exact"
430
583
  };
431
584
  }
432
- if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) return {};
585
+ if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) {
586
+ return { mappingStatus: "unmapped" };
587
+ }
433
588
  const value = text ?? "";
434
589
  let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? offsetHint : -1;
435
- if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value)
590
+ let mappingStatus = "exact";
591
+ if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value) {
436
592
  localStart = -1;
593
+ mappingStatus = "fallback";
594
+ }
437
595
  if (localStart < 0 || localStart > sourceText.length) {
438
596
  localStart = value ? sourceText.indexOf(value, sourceEventCursor) : sourceEventCursor;
439
597
  if (localStart < 0) localStart = value ? sourceText.indexOf(value) : sourceEventCursor;
598
+ mappingStatus = "fallback";
440
599
  }
441
600
  localStart = Math.max(0, localStart);
442
601
  const localEnd = Math.min(sourceText.length, localStart + value.length);
@@ -447,7 +606,8 @@ async function synthesizeSsml(ssml, config) {
447
606
  return {
448
607
  originalTextRange: { ...fallbackRange },
449
608
  textRange: { ...fallbackRange },
450
- ...segment ? { sourceNodePath: [...segment.sourceNodePath] } : config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {}
609
+ ...segment ? { sourceNodePath: [...segment.sourceNodePath] } : config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
610
+ mappingStatus: segment || config.sourceTextRange || config.sourceNodePath ? mappingStatus : "unmapped"
451
611
  };
452
612
  };
453
613
  synthesizer.wordBoundary = (_sender, event) => {
@@ -496,20 +656,32 @@ async function synthesizeSsml(ssml, config) {
496
656
  );
497
657
  const durationMs = result.audioDuration ? ticksToMilliseconds(result.audioDuration) : eventDurationMs;
498
658
  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
- });
659
+ const addSourceMetadata = (event) => {
660
+ const mapped = {
661
+ ...event,
662
+ ...config.sourceTextRange && !("textRange" in event) ? { textRange: { ...config.sourceTextRange } } : {},
663
+ ...config.sourceTextRange && !("originalTextRange" in event) ? { originalTextRange: { ...config.sourceTextRange } } : {},
664
+ ...config.chunkIndex !== void 0 ? { chunkIndex: config.chunkIndex } : {},
665
+ ...config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
666
+ ...requestId ? { requestId } : {}
667
+ };
668
+ if (event.mappingStatus === "unmapped") {
669
+ Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
670
+ Object.defineProperty(mapped, "toJSON", {
671
+ value: () => ({ ...mapped, mappingStatus: "unmapped" }),
672
+ enumerable: false
673
+ });
674
+ }
675
+ return mapped;
676
+ };
507
677
  const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
508
678
  const sourceVisemes = visemes.map((viseme) => addSourceMetadata(viseme));
509
679
  const sourceBookmarks = bookmarks.map((bookmark) => addSourceMetadata(bookmark));
510
680
  resolve({
511
681
  audioData: result.audioData,
512
682
  durationMs,
683
+ audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
684
+ mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
513
685
  ...config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {},
514
686
  ...requestId ? { requestId } : {},
515
687
  ...sourceBoundaries.length > 0 ? { boundaries: sourceBoundaries, wordBoundary: sourceBoundaries, wordBoundaries: sourceBoundaries } : {},
@@ -522,10 +694,11 @@ async function synthesizeSsml(ssml, config) {
522
694
  abortHandler = () => rejectWithError(new SynthesisCancelledError());
523
695
  config.signal.addEventListener("abort", abortHandler, { once: true });
524
696
  }
525
- if (config.timeoutMs !== void 0 && config.timeoutMs > 0) {
697
+ const timeoutMs = config.timeouts?.perChunkMs ?? config.timeoutMs;
698
+ if (timeoutMs !== void 0 && timeoutMs > 0) {
526
699
  timeout = setTimeout(
527
- () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeoutMs} ms.`)),
528
- config.timeoutMs
700
+ () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
701
+ timeoutMs
529
702
  );
530
703
  }
531
704
  synthesizer.speakSsmlAsync(ssml, cb, rejectWithError);
@@ -534,9 +707,109 @@ async function synthesizeSsml(ssml, config) {
534
707
  }
535
708
  });
536
709
  }
710
+ function isRetryableSynthesisError(error) {
711
+ if (error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError) return false;
712
+ if (error instanceof AzureTtsError && error.status !== 0)
713
+ return error.status === 429 || error.status >= 500 && error.status < 600;
714
+ const message = error instanceof Error ? error.message : String(error);
715
+ if (/\b4\d{2}\b/.test(message)) return false;
716
+ const status = error && typeof error === "object" && "status" in error ? error.status : void 0;
717
+ if (typeof status === "number") return status === 429 || status >= 500 && status < 600;
718
+ return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
719
+ }
720
+ function retryDelay(options, retryAttempt, error) {
721
+ const retryAfterMs = getRetryAfterDelayMs(error);
722
+ if (retryAfterMs !== void 0) return retryAfterMs;
723
+ const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
724
+ return Math.floor(Math.random() * (base + 1));
725
+ }
726
+ function resolveConcurrency(value, total) {
727
+ if (value === void 0) return 1;
728
+ if (value === Infinity) return Math.max(1, total);
729
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
730
+ }
731
+ async function waitForRetry(delayMs, signal) {
732
+ if (signal?.aborted) throw new SynthesisCancelledError();
733
+ if (delayMs <= 0) return;
734
+ await new Promise((resolve, reject) => {
735
+ let timer;
736
+ const abort = () => {
737
+ clearTimeout(timer);
738
+ signal?.removeEventListener("abort", abort);
739
+ reject(new SynthesisCancelledError());
740
+ };
741
+ timer = setTimeout(() => {
742
+ signal?.removeEventListener("abort", abort);
743
+ resolve();
744
+ }, delayMs);
745
+ if (signal) {
746
+ signal.addEventListener("abort", abort, { once: true });
747
+ }
748
+ });
749
+ }
750
+ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
751
+ const options = retryOptions ? {
752
+ maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
753
+ initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
754
+ maxDelayMs: Math.max(0, retryOptions.maxDelayMs),
755
+ shouldRetry: retryOptions.shouldRetry
756
+ } : void 0;
757
+ let attempt = 0;
758
+ while (true) {
759
+ if (config.signal?.aborted) throw new SynthesisCancelledError();
760
+ try {
761
+ return await synthesizeSsml(ssml, config);
762
+ } catch (error) {
763
+ if (!options || attempt >= options.maxRetries || !(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error)))
764
+ throw error;
765
+ attempt += 1;
766
+ const delayMs = retryDelay(options, attempt, error);
767
+ onRetry(attempt, delayMs);
768
+ await waitForRetry(delayMs, config.signal);
769
+ }
770
+ }
771
+ }
772
+ function createAbortScope(parent, timeoutMs) {
773
+ const controller = new AbortController();
774
+ let didTimeout = false;
775
+ const onAbort = () => controller.abort();
776
+ if (parent?.aborted) controller.abort();
777
+ parent?.addEventListener("abort", onAbort, { once: true });
778
+ const timer = timeoutMs !== void 0 && timeoutMs > 0 ? setTimeout(() => {
779
+ didTimeout = true;
780
+ controller.abort();
781
+ }, timeoutMs) : void 0;
782
+ return {
783
+ signal: controller.signal,
784
+ timedOut: () => didTimeout,
785
+ dispose: () => {
786
+ if (timer) clearTimeout(timer);
787
+ parent?.removeEventListener("abort", onAbort);
788
+ },
789
+ abort: () => controller.abort()
790
+ };
791
+ }
792
+ async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs, onRetry) {
793
+ const scope = createAbortScope(config.signal, timeoutMs);
794
+ try {
795
+ return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry);
796
+ } catch (error) {
797
+ if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
798
+ throw error;
799
+ } finally {
800
+ scope.dispose();
801
+ }
802
+ }
537
803
  async function synthesizeSsmlChunks(chunks, config) {
538
- const results = [];
804
+ const results = new Array(chunks.length);
539
805
  const totalChunks = chunks.length;
806
+ const cachedChunks = new Map((config.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
807
+ for (const [index, cached] of cachedChunks) {
808
+ if (index >= 0 && index < totalChunks) results[index] = cached;
809
+ }
810
+ const requestedIndices = config.resumeChunkIndices ? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks)) : void 0;
811
+ const shouldSynthesize = (index) => !cachedChunks.has(index) && (requestedIndices === void 0 || requestedIndices.has(index));
812
+ const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
540
813
  const report = (event) => config.onProgress?.(event);
541
814
  for (const [index, chunk] of chunks.entries()) {
542
815
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
@@ -550,57 +823,112 @@ async function synthesizeSsmlChunks(chunks, config) {
550
823
  durationMs: 0
551
824
  });
552
825
  }
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) {
826
+ let completed = [...results].filter((result) => result !== void 0).length;
827
+ let nextIndex = 0;
828
+ const concurrency = resolveConcurrency(config.concurrency, chunks.length);
829
+ let firstError;
830
+ const failedIndices = /* @__PURE__ */ new Set();
831
+ const worker = async () => {
832
+ while (true) {
833
+ const index = nextIndex++;
834
+ if (index >= chunks.length) return;
835
+ if (!shouldSynthesize(index)) continue;
836
+ if (firstError && config.cancelOnFailure !== false) return;
837
+ const chunk = chunks[index];
838
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
586
839
  report({
587
- currentChunk: index,
840
+ currentChunk: completed,
588
841
  totalChunks,
589
- percent: totalChunks === 0 ? 100 : Math.round(index / totalChunks * 100),
842
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
590
843
  chunkIndex: index,
591
844
  originalTextRange: input.originalTextRange,
592
- status: "failed",
593
- durationMs: Date.now() - startedAt,
594
- error
845
+ status: "synthesizing",
846
+ durationMs: 0
595
847
  });
596
- throw error;
848
+ const startedAt = Date.now();
849
+ try {
850
+ const result = await synthesizeChunkWithTimeout(
851
+ input.ssml,
852
+ {
853
+ ...config,
854
+ signal: scope.signal,
855
+ ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
856
+ ...input.sourceNodePath ?? config.sourceNodePath ? { sourceNodePath: [...input.sourceNodePath ?? config.sourceNodePath ?? []] } : {},
857
+ ...input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {},
858
+ ...input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {},
859
+ chunkIndex: index,
860
+ onProgress: void 0
861
+ },
862
+ config.retryOptions,
863
+ config.timeouts?.chunkWithRetriesMs ?? config.timeouts?.perChunkMs ?? config.timeoutMs,
864
+ (retryAttempt, nextRetryDelayMs) => report({
865
+ currentChunk: completed,
866
+ totalChunks,
867
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
868
+ chunkIndex: index,
869
+ originalTextRange: input.originalTextRange,
870
+ status: "synthesizing",
871
+ durationMs: Date.now() - startedAt,
872
+ retryAttempt,
873
+ nextRetryDelayMs,
874
+ isRetrying: true
875
+ })
876
+ );
877
+ results[index] = result;
878
+ completed += 1;
879
+ report({
880
+ currentChunk: completed,
881
+ totalChunks,
882
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
883
+ chunkIndex: index,
884
+ originalTextRange: input.originalTextRange,
885
+ status: "success",
886
+ durationMs: Date.now() - startedAt
887
+ });
888
+ } catch (error) {
889
+ failedIndices.add(index);
890
+ report({
891
+ currentChunk: completed,
892
+ totalChunks,
893
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
894
+ chunkIndex: index,
895
+ originalTextRange: input.originalTextRange,
896
+ status: "failed",
897
+ durationMs: Date.now() - startedAt,
898
+ error
899
+ });
900
+ firstError ?? (firstError = scope.timedOut() ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`) : error);
901
+ if (config.cancelOnFailure !== false) scope.abort();
902
+ return;
903
+ }
597
904
  }
905
+ };
906
+ try {
907
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
908
+ if (firstError) throw firstError;
909
+ const orderedResults = results.filter((result) => result !== void 0);
910
+ return await mergeSynthesisResults(orderedResults, {
911
+ format: config.outputFormat ?? DEFAULT_OUTPUT_FORMAT,
912
+ signal: scope.signal,
913
+ customMerger: config.customMerger,
914
+ outputMimeType: config.outputMimeType,
915
+ postMergeValidator: config.postMergeValidator
916
+ });
917
+ } catch (error) {
918
+ const partial = {
919
+ synthesizedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
920
+ completedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
921
+ pendingChunkIndices: chunks.flatMap((_chunk, chunkIndex) => results[chunkIndex] ? [] : [chunkIndex]),
922
+ failedChunkIndices: [...failedIndices],
923
+ totalChunks
924
+ };
925
+ if (error && typeof error === "object") error.partialResult = partial;
926
+ throw error;
927
+ } finally {
928
+ scope.dispose();
598
929
  }
599
- return mergeSynthesisResults(results, {
600
- format: config.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3"
601
- });
602
930
  }
603
- function createMergedResult(results, audioData, format) {
931
+ function createMergedResult(results, audioData, format, audioSpec, outputMimeType) {
604
932
  const boundaries = [];
605
933
  const visemes = [];
606
934
  const bookmarks = [];
@@ -619,7 +947,8 @@ function createMergedResult(results, audioData, format) {
619
947
  ...boundary.sourceNodePath ? { sourceNodePath: [...boundary.sourceNodePath] } : {},
620
948
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
621
949
  ...textRange ? { textRange: { ...textRange } } : {},
622
- ...requestId ? { requestId } : {}
950
+ ...requestId ? { requestId } : {},
951
+ mappingStatus: boundary.mappingStatus ?? "unmapped"
623
952
  });
624
953
  }
625
954
  for (const viseme of result.visemes ?? []) {
@@ -634,7 +963,8 @@ function createMergedResult(results, audioData, format) {
634
963
  ...viseme.sourceNodePath ? { sourceNodePath: [...viseme.sourceNodePath] } : {},
635
964
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
636
965
  ...textRange ? { textRange: { ...textRange } } : {},
637
- ...requestId ? { requestId } : {}
966
+ ...requestId ? { requestId } : {},
967
+ mappingStatus: viseme.mappingStatus ?? "unmapped"
638
968
  });
639
969
  }
640
970
  for (const bookmark of result.bookmarks ?? []) {
@@ -649,7 +979,8 @@ function createMergedResult(results, audioData, format) {
649
979
  ...bookmark.sourceNodePath ? { sourceNodePath: [...bookmark.sourceNodePath] } : {},
650
980
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
651
981
  ...textRange ? { textRange: { ...textRange } } : {},
652
- ...requestId ? { requestId } : {}
982
+ ...requestId ? { requestId } : {},
983
+ mappingStatus: bookmark.mappingStatus ?? "unmapped"
653
984
  });
654
985
  }
655
986
  durationOffset += Math.max(0, result.durationMs);
@@ -658,6 +989,8 @@ function createMergedResult(results, audioData, format) {
658
989
  audioData,
659
990
  durationMs: durationOffset,
660
991
  mimeType: resolveMimeType(format),
992
+ audioSpec: audioSpec ?? formatAudioSpecification(format),
993
+ ...outputMimeType ? { mimeType: outputMimeType } : {},
661
994
  ...boundaries.length > 0 ? { boundaries, wordBoundary: boundaries, wordBoundaries: boundaries } : {},
662
995
  ...visemes.length > 0 ? { visemes } : {},
663
996
  ...bookmarks.length > 0 ? { bookmarks } : {},
@@ -670,19 +1003,73 @@ function mergeSynthesisResults(results, options) {
670
1003
  const format = resolvedOptions?.format;
671
1004
  if (!format) throw new UnsupportedMergeFormatError("");
672
1005
  const buffers = results.map((result) => result.audioData);
1006
+ const inputSpecs = results.map((result) => result.audioSpec ?? inspectAudioSpecification(result.audioData, format));
1007
+ validateAudioSpecifications(inputSpecs);
1008
+ const signal = resolvedOptions.signal ?? new AbortController().signal;
1009
+ if (signal.aborted) throw new SynthesisCancelledError();
673
1010
  if (resolvedOptions.customMerger) {
674
- return Promise.resolve().then(() => resolvedOptions.customMerger?.(buffers, format)).then((merged) => {
1011
+ return Promise.resolve().then(
1012
+ () => resolvedOptions.customMerger?.(buffers, {
1013
+ format,
1014
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
1015
+ inputSpecs,
1016
+ signal
1017
+ })
1018
+ ).then((merged) => {
675
1019
  if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
676
- return createMergedResult(results, merged, format);
1020
+ if (!(merged instanceof ArrayBuffer) || buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
1021
+ throw new MergeError("The custom audio merger returned an invalid audio buffer.");
1022
+ if (signal.aborted) throw new SynthesisCancelledError();
1023
+ const result = createMergedResult(
1024
+ results,
1025
+ merged,
1026
+ format,
1027
+ inspectAudioSpecification(merged, format),
1028
+ resolvedOptions.outputMimeType
1029
+ );
1030
+ return Promise.resolve(
1031
+ resolvedOptions.postMergeValidator?.(result, {
1032
+ format,
1033
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
1034
+ inputSpecs,
1035
+ signal
1036
+ })
1037
+ ).then((valid) => {
1038
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1039
+ return result;
1040
+ });
677
1041
  }).catch((error) => {
678
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
1042
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
1043
+ throw error;
679
1044
  throw new MergeError(`Custom audio merger failed for format "${format}".`, error);
680
1045
  });
681
1046
  }
682
1047
  try {
683
- return createMergedResult(results, mergeAudioBuffers(buffers, { format }), format);
1048
+ const result = createMergedResult(
1049
+ results,
1050
+ mergeAudioBuffers(buffers, { format }),
1051
+ format,
1052
+ inputSpecs[0],
1053
+ resolvedOptions.outputMimeType
1054
+ );
1055
+ if (resolvedOptions.postMergeValidator) {
1056
+ const validation = resolvedOptions.postMergeValidator(result, {
1057
+ format,
1058
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
1059
+ inputSpecs,
1060
+ signal
1061
+ });
1062
+ if (validation instanceof Promise)
1063
+ return validation.then((valid) => {
1064
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1065
+ return result;
1066
+ });
1067
+ if (validation === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1068
+ }
1069
+ return result;
684
1070
  } catch (error) {
685
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
1071
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
1072
+ throw error;
686
1073
  throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
687
1074
  }
688
1075
  }
@@ -701,11 +1088,125 @@ var ChunkValidationError = class extends Error {
701
1088
  this.diagnostics = diagnostics;
702
1089
  }
703
1090
  };
704
- function failure(error) {
705
- return { ok: false, success: false, status: error.kind, error };
1091
+ var BatchChunkValidationError = class extends ChunkValidationError {
1092
+ constructor(chunkDiagnostics) {
1093
+ const first = chunkDiagnostics[0];
1094
+ super(first?.chunkIndex ?? -1, first?.diagnostics ?? []);
1095
+ this.name = "BatchChunkValidationError";
1096
+ this.message = `SSML validation failed for ${chunkDiagnostics.length} chunk(s); the Azure Speech API was not called.`;
1097
+ this.chunkDiagnostics = chunkDiagnostics;
1098
+ this.totalErrorCount = chunkDiagnostics.reduce((total, chunk) => total + chunk.diagnostics.length, 0);
1099
+ this.errorCount = this.totalErrorCount;
1100
+ this.totalErrors = this.totalErrorCount;
1101
+ }
1102
+ };
1103
+ function failure(error, partialResult) {
1104
+ return {
1105
+ ok: false,
1106
+ success: false,
1107
+ status: error.kind,
1108
+ error,
1109
+ ...partialResult ? { partialResult } : {}
1110
+ };
1111
+ }
1112
+ function partialResultFrom(error) {
1113
+ if (!error || typeof error !== "object") return void 0;
1114
+ const partial = error.partialResult;
1115
+ if (!partial || typeof partial !== "object") return void 0;
1116
+ return partial;
1117
+ }
1118
+ function createSafeAbortScope(parent, timeoutMs) {
1119
+ const controller = new AbortController();
1120
+ let didTimeout = false;
1121
+ const onAbort = () => controller.abort();
1122
+ if (parent?.aborted) controller.abort();
1123
+ parent?.addEventListener("abort", onAbort, { once: true });
1124
+ const timer = timeoutMs !== void 0 && timeoutMs > 0 ? setTimeout(() => {
1125
+ didTimeout = true;
1126
+ controller.abort();
1127
+ }, timeoutMs) : void 0;
1128
+ return {
1129
+ signal: controller.signal,
1130
+ timedOut: () => didTimeout,
1131
+ dispose: () => {
1132
+ if (timer) clearTimeout(timer);
1133
+ parent?.removeEventListener("abort", onAbort);
1134
+ },
1135
+ abort: () => controller.abort()
1136
+ };
1137
+ }
1138
+ function isRetryable(error) {
1139
+ if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
1140
+ const status = error && typeof error === "object" && "status" in error ? error.status : void 0;
1141
+ if (typeof status === "number" && status !== 0) return status === 429 || status >= 500 && status < 600;
1142
+ const message = error instanceof Error ? error.message : String(error);
1143
+ if (/\b4\d{2}\b/.test(message)) return false;
1144
+ return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
1145
+ }
1146
+ function delayForRetry(options, attempt) {
1147
+ const maxDelay = Math.max(0, options.maxDelayMs);
1148
+ const base = Math.min(maxDelay, Math.max(0, options.initialDelayMs) * 2 ** Math.max(0, attempt - 1));
1149
+ return Math.floor(Math.random() * (base + 1));
1150
+ }
1151
+ function retryDelayForError(options, attempt, error) {
1152
+ return getRetryAfterDelayMs(error) ?? delayForRetry(options, attempt);
1153
+ }
1154
+ function resolveConcurrency2(value, total) {
1155
+ if (value === void 0) return 1;
1156
+ if (value === Infinity) return Math.max(1, total);
1157
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
1158
+ }
1159
+ async function retryableSynthesis(synthesize, options, signal, onRetry) {
1160
+ const retry = options ? {
1161
+ maxRetries: Math.max(0, Math.floor(options.maxRetries)),
1162
+ initialDelayMs: options.initialDelayMs,
1163
+ maxDelayMs: options.maxDelayMs,
1164
+ shouldRetry: options.shouldRetry
1165
+ } : void 0;
1166
+ let attempt = 0;
1167
+ while (true) {
1168
+ if (signal?.aborted) throw new Error("Speech synthesis was cancelled.");
1169
+ try {
1170
+ return await synthesize();
1171
+ } catch (error) {
1172
+ if (!retry || attempt >= retry.maxRetries || !(retry.shouldRetry?.(error, attempt + 1) ?? isRetryable(error)))
1173
+ throw error;
1174
+ attempt += 1;
1175
+ const delayMs = retryDelayForError(retry, attempt, error);
1176
+ onRetry(attempt, delayMs);
1177
+ if (delayMs > 0)
1178
+ await new Promise((resolve, reject) => {
1179
+ const timer = setTimeout(() => {
1180
+ signal?.removeEventListener("abort", abort);
1181
+ resolve();
1182
+ }, delayMs);
1183
+ const abort = () => {
1184
+ clearTimeout(timer);
1185
+ signal?.removeEventListener("abort", abort);
1186
+ reject(new Error("Speech synthesis was cancelled."));
1187
+ };
1188
+ signal?.addEventListener("abort", abort, { once: true });
1189
+ });
1190
+ }
1191
+ }
1192
+ }
1193
+ function sharedValidationOptions(options, signal) {
1194
+ const validator = options.urlValidator ?? options.customUrlValidator;
1195
+ if (!validator) return signal ? withValidationSignal(options, signal) : options;
1196
+ const runner = (0, import_ssml_core2.createAzureUrlValidatorRunner)(validator, {
1197
+ ...options.urlValidation ?? {},
1198
+ ...options.urlValidatorConcurrency !== void 0 ? { concurrency: options.urlValidatorConcurrency } : {},
1199
+ ...options.timeouts?.urlValidationMs !== void 0 ? { timeoutMs: options.timeouts.urlValidationMs } : options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
1200
+ ...signal ? { signal } : {},
1201
+ ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
1202
+ });
1203
+ return {
1204
+ ...withValidationSignal(options, signal),
1205
+ urlValidatorRunner: runner
1206
+ };
706
1207
  }
707
1208
  async function synthesizeSsmlSafe(client, ssml, options = {}) {
708
- const validationOptions = withValidationSignal(options.validation ?? options, options.signal);
1209
+ const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
709
1210
  const diagnostics = await Promise.resolve((0, import_ssml_core2.validateAzureSsml)(ssml, validationOptions));
710
1211
  if (options.signal?.aborted) {
711
1212
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
@@ -724,7 +1225,11 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
724
1225
  ok: true,
725
1226
  success: true,
726
1227
  status: "success",
727
- value: await client.synthesizeSsml(ssml, { signal: options.signal })
1228
+ value: await client.synthesizeSsml(ssml, {
1229
+ signal: options.signal,
1230
+ timeoutMs: options.timeouts?.perChunkMs,
1231
+ timeouts: options.timeouts
1232
+ })
728
1233
  };
729
1234
  } catch (error) {
730
1235
  const synthesisError = toSynthesisError(error);
@@ -732,7 +1237,10 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
732
1237
  }
733
1238
  }
734
1239
  async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
735
- const validationOptions = withValidationSignal(options.validation ?? options, options.signal);
1240
+ const validationOptions = sharedValidationOptions(
1241
+ { ...options.validation ?? options, timeouts: options.timeouts },
1242
+ options.signal
1243
+ );
736
1244
  if (options.signal?.aborted) {
737
1245
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
738
1246
  return failure(error);
@@ -753,21 +1261,30 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
753
1261
  pending(index, "pending");
754
1262
  });
755
1263
  const validations = await Promise.all(
756
- chunks.map(async (chunk) => {
1264
+ chunks.map(async (chunk, index) => {
757
1265
  const ssml = typeof chunk === "string" ? chunk : chunk.ssml;
758
1266
  const sourceNodePath = typeof chunk === "string" ? options.sourceNodePath : chunk.sourceNodePath ?? options.sourceNodePath;
759
1267
  const diagnostics = await Promise.resolve(
760
- (0, import_ssml_core2.validateAzureSsml)(ssml, { ...validationOptions, ...sourceNodePath ? { sourceNodePath } : {} })
1268
+ (0, import_ssml_core2.validateAzureSsml)(ssml, {
1269
+ ...validationOptions,
1270
+ ...sourceNodePath ? { sourceNodePath } : {},
1271
+ chunkIndex: index
1272
+ })
761
1273
  );
762
1274
  return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
763
1275
  })
764
1276
  );
765
- const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
766
- if (firstInvalidIndex >= 0) {
767
- const error = new ChunkValidationError(firstInvalidIndex, validations[firstInvalidIndex] ?? []);
768
- pending(firstInvalidIndex, "failed", error);
1277
+ if (options.signal?.aborted) {
1278
+ const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
769
1279
  return failure(error);
770
1280
  }
1281
+ const chunkDiagnostics = validations.map((diagnostics, chunkIndex) => ({ chunkIndex, diagnostics })).filter((entry) => entry.diagnostics.length > 0);
1282
+ if (chunkDiagnostics.length > 0) {
1283
+ const error = new BatchChunkValidationError(chunkDiagnostics);
1284
+ for (const entry of chunkDiagnostics) pending(entry.chunkIndex, "failed", error);
1285
+ return failure(error);
1286
+ }
1287
+ let fallbackJobScope;
771
1288
  try {
772
1289
  if (client.synthesizeChunks) {
773
1290
  const normalizedChunks = chunks.map((chunk) => {
@@ -779,101 +1296,181 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
779
1296
  outputFormat: options.outputFormat,
780
1297
  signal: options.signal,
781
1298
  timeoutMs: options.timeoutMs,
782
- sourceNodePath: options.sourceNodePath
1299
+ timeouts: options.timeouts,
1300
+ sourceNodePath: options.sourceNodePath,
1301
+ concurrency: options.concurrency,
1302
+ retryOptions: options.retryOptions,
1303
+ cancelOnFailure: options.cancelOnFailure,
1304
+ resumeChunks: options.resumeChunks,
1305
+ resumeChunkIndices: options.resumeChunkIndices,
1306
+ customMerger: options.customMerger,
1307
+ outputMimeType: options.outputMimeType,
1308
+ postMergeValidator: options.postMergeValidator
783
1309
  });
784
1310
  return { ok: true, success: true, status: "success", value };
785
1311
  }
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;
1312
+ const results = new Array(chunks.length);
1313
+ const cachedChunks = new Map((options.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
1314
+ for (const [index, cached] of cachedChunks) {
1315
+ if (index >= 0 && index < chunks.length) results[index] = cached;
1316
+ }
1317
+ const requestedIndices = options.resumeChunkIndices ? new Set(options.resumeChunkIndices.filter((index) => index >= 0 && index < chunks.length)) : void 0;
1318
+ const shouldSynthesize = (index) => !cachedChunks.has(index) && (requestedIndices === void 0 || requestedIndices.has(index));
1319
+ const jobScope = chunks.length > 1 || options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs) : void 0;
1320
+ fallbackJobScope = jobScope;
1321
+ const failedIndices = /* @__PURE__ */ new Set();
1322
+ let firstError;
1323
+ let completed = [...results].filter((result) => result !== void 0).length;
1324
+ let nextIndex = 0;
1325
+ const concurrency = resolveConcurrency2(options.concurrency, chunks.length);
1326
+ const worker = async () => {
1327
+ while (true) {
1328
+ const index = nextIndex++;
1329
+ if (index >= chunks.length) return;
1330
+ if (!shouldSynthesize(index)) continue;
1331
+ if (failedIndices.size > 0 && options.cancelOnFailure !== false) return;
1332
+ const chunk = chunks[index];
1333
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
1334
+ const sourceNodePath = input.sourceNodePath;
1335
+ const originalTextRange = input.originalTextRange;
1336
+ pending(index, "synthesizing");
1337
+ const startedAt = Date.now();
1338
+ try {
1339
+ const chunkTimeout = options.timeouts?.chunkWithRetriesMs ?? options.timeouts?.perChunkMs;
1340
+ const chunkScope = chunkTimeout !== void 0 || jobScope ? createSafeAbortScope(jobScope?.signal ?? options.signal, chunkTimeout ?? options.timeoutMs) : void 0;
1341
+ const chunkSignal = chunkScope?.signal ?? options.signal;
1342
+ let result;
1343
+ try {
1344
+ result = await retryableSynthesis(
1345
+ () => client.synthesizeSsml(input.ssml, {
1346
+ outputFormat: options.outputFormat,
1347
+ signal: chunkSignal,
1348
+ timeoutMs: options.timeouts?.perChunkMs ?? options.timeoutMs,
1349
+ sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
1350
+ }),
1351
+ options.retryOptions,
1352
+ chunkSignal,
1353
+ (retryAttempt, nextRetryDelayMs) => options.onProgress?.({
1354
+ currentChunk: completed,
1355
+ totalChunks: chunks.length,
1356
+ percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1357
+ chunkIndex: index,
1358
+ originalTextRange: input.originalTextRange,
1359
+ status: "synthesizing",
1360
+ durationMs: Date.now() - startedAt,
1361
+ retryAttempt,
1362
+ nextRetryDelayMs,
1363
+ isRetrying: true
1364
+ })
1365
+ );
1366
+ } catch (error) {
1367
+ if (chunkScope?.timedOut())
1368
+ throw new Error(`Speech synthesis timed out after ${chunkTimeout ?? options.timeoutMs} ms.`);
1369
+ throw error;
1370
+ } finally {
1371
+ chunkScope?.dispose();
1372
+ }
1373
+ results[index] = {
1374
+ ...result,
1375
+ ...input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {},
1376
+ ...sourceNodePath ? {
1377
+ boundaries: result.boundaries?.map((event) => ({
1378
+ ...event,
1379
+ sourceNodePath: [...sourceNodePath],
1380
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1381
+ })),
1382
+ visemes: result.visemes?.map((event) => ({
1383
+ ...event,
1384
+ sourceNodePath: [...sourceNodePath],
1385
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1386
+ })),
1387
+ bookmarks: result.bookmarks?.map((event) => ({
1388
+ ...event,
1389
+ sourceNodePath: [...sourceNodePath],
1390
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1391
+ }))
1392
+ } : {},
1393
+ ...originalTextRange ? {
1394
+ boundaries: result.boundaries?.map((event) => ({
1395
+ ...event,
1396
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1397
+ })),
1398
+ wordBoundary: result.wordBoundary?.map((event) => ({
1399
+ ...event,
1400
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1401
+ })),
1402
+ wordBoundaries: result.wordBoundaries?.map((event) => ({
1403
+ ...event,
1404
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1405
+ })),
1406
+ visemes: result.visemes?.map((event) => ({
1407
+ ...event,
1408
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1409
+ })),
1410
+ bookmarks: result.bookmarks?.map((event) => ({
1411
+ ...event,
1412
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1413
+ }))
1414
+ } : {}
1415
+ };
1416
+ completed += 1;
1417
+ options.onProgress?.({
1418
+ currentChunk: completed,
1419
+ totalChunks: chunks.length,
1420
+ percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1421
+ chunkIndex: index,
1422
+ originalTextRange: input.originalTextRange,
1423
+ status: "success",
1424
+ durationMs: Date.now() - startedAt
1425
+ });
1426
+ } catch (error) {
1427
+ failedIndices.add(index);
1428
+ options.onProgress?.({
1429
+ currentChunk: completed,
1430
+ totalChunks: chunks.length,
1431
+ percent: chunks.length === 0 ? 100 : Math.round(index / chunks.length * 100),
1432
+ chunkIndex: index,
1433
+ originalTextRange: input.originalTextRange,
1434
+ status: "failed",
1435
+ durationMs: Date.now() - startedAt,
1436
+ error
1437
+ });
1438
+ if (options.cancelOnFailure !== false) jobScope?.abort();
1439
+ firstError ?? (firstError = error);
1440
+ return;
1441
+ }
864
1442
  }
1443
+ };
1444
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
1445
+ if (failedIndices.size > 0) {
1446
+ const error = firstError ?? new Error("One or more SSML chunks failed to synthesize.");
1447
+ error.partialResult = {
1448
+ synthesizedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
1449
+ completedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
1450
+ pendingChunkIndices: chunks.flatMap((_chunk, chunkIndex) => results[chunkIndex] ? [] : [chunkIndex]),
1451
+ failedChunkIndices: [...failedIndices],
1452
+ totalChunks: chunks.length
1453
+ };
1454
+ throw error;
865
1455
  }
1456
+ const orderedResults = results.filter((result) => result !== void 0);
866
1457
  return {
867
1458
  ok: true,
868
1459
  success: true,
869
1460
  status: "success",
870
- value: mergeSynthesisResults(results, {
871
- format: options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3"
1461
+ value: await mergeSynthesisResults(orderedResults, {
1462
+ format: options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3",
1463
+ signal: jobScope?.signal ?? options.signal,
1464
+ customMerger: options.customMerger,
1465
+ outputMimeType: options.outputMimeType,
1466
+ postMergeValidator: options.postMergeValidator
872
1467
  })
873
1468
  };
874
1469
  } catch (error) {
875
1470
  const synthesisError = toSynthesisError(error);
876
- return failure(synthesisError);
1471
+ return failure(synthesisError, partialResultFrom(error));
1472
+ } finally {
1473
+ fallbackJobScope?.dispose();
877
1474
  }
878
1475
  }
879
1476
  function withValidationSignal(options, signal) {
@@ -894,14 +1491,14 @@ var AzureTtsClient = class {
894
1491
  __privateSet(this, _options, options);
895
1492
  }
896
1493
  async synthesize(ssml) {
897
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1494
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
898
1495
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
899
1496
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
900
- const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs };
1497
+ const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts };
901
1498
  return synthesizeSpeech(ssml, config);
902
1499
  }
903
1500
  async synthesizeSsml(ssml, options = {}) {
904
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1501
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
905
1502
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
906
1503
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
907
1504
  return synthesizeSsml(ssml, {
@@ -911,13 +1508,14 @@ var AzureTtsClient = class {
911
1508
  outputFormat: options.outputFormat ?? outputFormat,
912
1509
  signal: options.signal ?? signal,
913
1510
  timeoutMs: options.timeoutMs ?? timeoutMs,
1511
+ timeouts: options.timeouts ?? timeouts,
914
1512
  sourceNodePath: options.sourceNodePath,
915
1513
  sourceTextSegments: options.sourceTextSegments,
916
1514
  sourceMarkers: options.sourceMarkers
917
1515
  });
918
1516
  }
919
1517
  async synthesizeChunks(chunks, options = {}) {
920
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1518
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
921
1519
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
922
1520
  return synthesizeSsmlChunks(chunks, {
923
1521
  endpoint,
@@ -926,8 +1524,17 @@ var AzureTtsClient = class {
926
1524
  outputFormat: options.outputFormat ?? outputFormat,
927
1525
  signal: options.signal ?? signal,
928
1526
  timeoutMs: options.timeoutMs ?? timeoutMs,
1527
+ timeouts: options.timeouts ?? timeouts,
929
1528
  sourceNodePath: options.sourceNodePath,
930
- onProgress: options.onProgress ?? __privateGet(this, _options).onProgress
1529
+ onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1530
+ concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1531
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
1532
+ cancelOnFailure: options.cancelOnFailure,
1533
+ resumeChunks: options.resumeChunks,
1534
+ resumeChunkIndices: options.resumeChunkIndices,
1535
+ customMerger: options.customMerger,
1536
+ outputMimeType: options.outputMimeType,
1537
+ postMergeValidator: options.postMergeValidator
931
1538
  });
932
1539
  }
933
1540
  async synthesizeSsmlSafe(ssml, options = {}) {
@@ -939,7 +1546,10 @@ var AzureTtsClient = class {
939
1546
  outputFormat: options.outputFormat ?? __privateGet(this, _options).outputFormat,
940
1547
  signal: options.signal ?? __privateGet(this, _options).signal,
941
1548
  timeoutMs: options.timeoutMs ?? __privateGet(this, _options).timeoutMs,
942
- onProgress: options.onProgress ?? __privateGet(this, _options).onProgress
1549
+ timeouts: options.timeouts ?? __privateGet(this, _options).timeouts,
1550
+ onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1551
+ concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1552
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
943
1553
  });
944
1554
  }
945
1555
  async synthesizeSsmlChunksSafe(chunks, options = {}) {
@@ -1030,15 +1640,19 @@ async function fetchAzureVoiceCatalog(options) {
1030
1640
  voiceCount: sortedVoices.length,
1031
1641
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1032
1642
  apiVersion: AZURE_VOICE_API_VERSION,
1033
- regions
1643
+ regions,
1644
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1e3).toISOString(),
1645
+ regionDiffs: {}
1034
1646
  }
1035
1647
  };
1036
1648
  }
1037
1649
  // Annotate the CommonJS export names for ESM import in node:
1038
1650
  0 && (module.exports = {
1651
+ AudioFormatMismatchError,
1039
1652
  AzureTtsClient,
1040
1653
  AzureTtsError,
1041
1654
  AzureTtsSdkError,
1655
+ BatchChunkValidationError,
1042
1656
  ChunkValidationError,
1043
1657
  DEFAULT_OUTPUT_FORMAT,
1044
1658
  MergeError,
@@ -1047,6 +1661,8 @@ async function fetchAzureVoiceCatalog(options) {
1047
1661
  UnsupportedMergeFormatError,
1048
1662
  canMergeAudioFormat,
1049
1663
  fetchAzureVoiceCatalog,
1664
+ getRetryAfterDelayMs,
1665
+ inspectAudioSpecification,
1050
1666
  mergeAudioBuffers,
1051
1667
  mergeSynthesisResults,
1052
1668
  resolveMergeAudioFormat,