@ssml-builder-js/azure-tts-client 2.16.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
@@ -41,6 +41,7 @@ __export(index_exports, {
41
41
  AzureTtsClient: () => AzureTtsClient,
42
42
  AzureTtsError: () => AzureTtsError,
43
43
  AzureTtsSdkError: () => AzureTtsSdkError,
44
+ BatchChunkValidationError: () => BatchChunkValidationError,
44
45
  ChunkValidationError: () => ChunkValidationError,
45
46
  DEFAULT_OUTPUT_FORMAT: () => DEFAULT_OUTPUT_FORMAT,
46
47
  MergeError: () => MergeError,
@@ -49,6 +50,7 @@ __export(index_exports, {
49
50
  UnsupportedMergeFormatError: () => UnsupportedMergeFormatError,
50
51
  canMergeAudioFormat: () => canMergeAudioFormat,
51
52
  fetchAzureVoiceCatalog: () => fetchAzureVoiceCatalog,
53
+ getRetryAfterDelayMs: () => getRetryAfterDelayMs,
52
54
  inspectAudioSpecification: () => inspectAudioSpecification,
53
55
  mergeAudioBuffers: () => mergeAudioBuffers,
54
56
  mergeSynthesisResults: () => mergeSynthesisResults,
@@ -64,7 +66,7 @@ module.exports = __toCommonJS(index_exports);
64
66
 
65
67
  // src/errors.ts
66
68
  var AzureTtsError = class extends Error {
67
- constructor(status, statusText, responseBody, requestId) {
69
+ constructor(status, statusText, responseBody, requestId, responseHeaders) {
68
70
  super(`Azure TTS request failed: ${status} ${statusText}`);
69
71
  this.kind = "azure-api-error";
70
72
  this.name = "AzureTtsError";
@@ -72,8 +74,37 @@ var AzureTtsError = class extends Error {
72
74
  this.statusText = statusText;
73
75
  this.responseBody = responseBody;
74
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());
75
82
  }
76
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
+ }
77
108
  var AzureTtsSdkError = class extends AzureTtsError {
78
109
  constructor(errorDetails) {
79
110
  super(0, "Speech SDK", errorDetails, null);
@@ -266,6 +297,8 @@ function formatAudioSpecification(format) {
266
297
  const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
267
298
  const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1e3 : void 0;
268
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;
269
302
  return {
270
303
  format,
271
304
  mimeType: resolveMimeType(format),
@@ -273,6 +306,9 @@ function formatAudioSpecification(format) {
273
306
  sampleRate,
274
307
  channels,
275
308
  ...bitrate ? { bitrate } : {},
309
+ ...bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {},
310
+ ...container ? { container } : {},
311
+ isVbr: /vbr/i.test(format),
276
312
  isCompressed: codec === "mp3" || codec === "opus" || codec === "silk"
277
313
  };
278
314
  }
@@ -308,6 +344,8 @@ function parseMp3Specification(buffer, format) {
308
344
  sampleRate,
309
345
  channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
310
346
  bitrate: bitrateKbps * 1e3,
347
+ container: "mp3-raw",
348
+ isVbr: false,
311
349
  isCompressed: true
312
350
  };
313
351
  }
@@ -329,6 +367,9 @@ function inspectAudioSpecification(buffer, format) {
329
367
  sampleRate,
330
368
  channels,
331
369
  ...sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {},
370
+ bitDepth: bitsPerSample,
371
+ container: "riff-wave",
372
+ isVbr: false,
332
373
  isCompressed: formatCode !== 1
333
374
  };
334
375
  }
@@ -339,7 +380,7 @@ function validateAudioSpecifications(specs) {
339
380
  const first = specs[0];
340
381
  if (!first) return;
341
382
  const mismatch = specs.find(
342
- (spec) => spec.sampleRate !== first.sampleRate || spec.channels !== first.channels || first.bitrate !== void 0 && spec.bitrate !== void 0 && spec.bitrate !== first.bitrate
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
343
384
  );
344
385
  if (mismatch)
345
386
  throw new AudioFormatMismatchError(
@@ -542,9 +583,7 @@ async function synthesizeSsml(ssml, config) {
542
583
  };
543
584
  }
544
585
  if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) {
545
- const unmapped = { mappingStatus: "unmapped" };
546
- Object.defineProperty(unmapped, "mappingStatus", { value: "unmapped", enumerable: false });
547
- return unmapped;
586
+ return { mappingStatus: "unmapped" };
548
587
  }
549
588
  const value = text ?? "";
550
589
  let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? offsetHint : -1;
@@ -626,8 +665,13 @@ async function synthesizeSsml(ssml, config) {
626
665
  ...config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
627
666
  ...requestId ? { requestId } : {}
628
667
  };
629
- if (event.mappingStatus === "unmapped")
668
+ if (event.mappingStatus === "unmapped") {
630
669
  Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
670
+ Object.defineProperty(mapped, "toJSON", {
671
+ value: () => ({ ...mapped, mappingStatus: "unmapped" }),
672
+ enumerable: false
673
+ });
674
+ }
631
675
  return mapped;
632
676
  };
633
677
  const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
@@ -650,10 +694,11 @@ async function synthesizeSsml(ssml, config) {
650
694
  abortHandler = () => rejectWithError(new SynthesisCancelledError());
651
695
  config.signal.addEventListener("abort", abortHandler, { once: true });
652
696
  }
653
- if (config.timeoutMs !== void 0 && config.timeoutMs > 0) {
697
+ const timeoutMs = config.timeouts?.perChunkMs ?? config.timeoutMs;
698
+ if (timeoutMs !== void 0 && timeoutMs > 0) {
654
699
  timeout = setTimeout(
655
- () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeoutMs} ms.`)),
656
- config.timeoutMs
700
+ () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
701
+ timeoutMs
657
702
  );
658
703
  }
659
704
  synthesizer.speakSsmlAsync(ssml, cb, rejectWithError);
@@ -672,7 +717,9 @@ function isRetryableSynthesisError(error) {
672
717
  if (typeof status === "number") return status === 429 || status >= 500 && status < 600;
673
718
  return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
674
719
  }
675
- function retryDelay(options, retryAttempt) {
720
+ function retryDelay(options, retryAttempt, error) {
721
+ const retryAfterMs = getRetryAfterDelayMs(error);
722
+ if (retryAfterMs !== void 0) return retryAfterMs;
676
723
  const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
677
724
  return Math.floor(Math.random() * (base + 1));
678
725
  }
@@ -704,7 +751,8 @@ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
704
751
  const options = retryOptions ? {
705
752
  maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
706
753
  initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
707
- maxDelayMs: Math.max(0, retryOptions.maxDelayMs)
754
+ maxDelayMs: Math.max(0, retryOptions.maxDelayMs),
755
+ shouldRetry: retryOptions.shouldRetry
708
756
  } : void 0;
709
757
  let attempt = 0;
710
758
  while (true) {
@@ -712,17 +760,56 @@ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
712
760
  try {
713
761
  return await synthesizeSsml(ssml, config);
714
762
  } catch (error) {
715
- if (!options || attempt >= options.maxRetries || !isRetryableSynthesisError(error)) throw error;
763
+ if (!options || attempt >= options.maxRetries || !(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error)))
764
+ throw error;
716
765
  attempt += 1;
717
- const delayMs = retryDelay(options, attempt);
766
+ const delayMs = retryDelay(options, attempt, error);
718
767
  onRetry(attempt, delayMs);
719
768
  await waitForRetry(delayMs, config.signal);
720
769
  }
721
770
  }
722
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
+ }
723
803
  async function synthesizeSsmlChunks(chunks, config) {
724
804
  const results = new Array(chunks.length);
725
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);
726
813
  const report = (event) => config.onProgress?.(event);
727
814
  for (const [index, chunk] of chunks.entries()) {
728
815
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
@@ -736,13 +823,17 @@ async function synthesizeSsmlChunks(chunks, config) {
736
823
  durationMs: 0
737
824
  });
738
825
  }
739
- let completed = 0;
826
+ let completed = [...results].filter((result) => result !== void 0).length;
740
827
  let nextIndex = 0;
741
828
  const concurrency = resolveConcurrency(config.concurrency, chunks.length);
829
+ let firstError;
830
+ const failedIndices = /* @__PURE__ */ new Set();
742
831
  const worker = async () => {
743
832
  while (true) {
744
833
  const index = nextIndex++;
745
834
  if (index >= chunks.length) return;
835
+ if (!shouldSynthesize(index)) continue;
836
+ if (firstError && config.cancelOnFailure !== false) return;
746
837
  const chunk = chunks[index];
747
838
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
748
839
  report({
@@ -756,10 +847,11 @@ async function synthesizeSsmlChunks(chunks, config) {
756
847
  });
757
848
  const startedAt = Date.now();
758
849
  try {
759
- const result = await synthesizeWithRetry(
850
+ const result = await synthesizeChunkWithTimeout(
760
851
  input.ssml,
761
852
  {
762
853
  ...config,
854
+ signal: scope.signal,
763
855
  ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
764
856
  ...input.sourceNodePath ?? config.sourceNodePath ? { sourceNodePath: [...input.sourceNodePath ?? config.sourceNodePath ?? []] } : {},
765
857
  ...input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {},
@@ -768,6 +860,7 @@ async function synthesizeSsmlChunks(chunks, config) {
768
860
  onProgress: void 0
769
861
  },
770
862
  config.retryOptions,
863
+ config.timeouts?.chunkWithRetriesMs ?? config.timeouts?.perChunkMs ?? config.timeoutMs,
771
864
  (retryAttempt, nextRetryDelayMs) => report({
772
865
  currentChunk: completed,
773
866
  totalChunks,
@@ -793,6 +886,7 @@ async function synthesizeSsmlChunks(chunks, config) {
793
886
  durationMs: Date.now() - startedAt
794
887
  });
795
888
  } catch (error) {
889
+ failedIndices.add(index);
796
890
  report({
797
891
  currentChunk: completed,
798
892
  totalChunks,
@@ -803,16 +897,36 @@ async function synthesizeSsmlChunks(chunks, config) {
803
897
  durationMs: Date.now() - startedAt,
804
898
  error
805
899
  });
806
- throw error;
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;
807
903
  }
808
904
  }
809
905
  };
810
- await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
811
- const orderedResults = results.filter((result) => result !== void 0);
812
- return mergeSynthesisResults(orderedResults, {
813
- format: config.outputFormat ?? DEFAULT_OUTPUT_FORMAT,
814
- signal: config.signal
815
- });
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();
929
+ }
816
930
  }
817
931
  function createMergedResult(results, audioData, format, audioSpec, outputMimeType) {
818
932
  const boundaries = [];
@@ -906,13 +1020,24 @@ function mergeSynthesisResults(results, options) {
906
1020
  if (!(merged instanceof ArrayBuffer) || buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
907
1021
  throw new MergeError("The custom audio merger returned an invalid audio buffer.");
908
1022
  if (signal.aborted) throw new SynthesisCancelledError();
909
- return createMergedResult(
1023
+ const result = createMergedResult(
910
1024
  results,
911
1025
  merged,
912
1026
  format,
913
1027
  inspectAudioSpecification(merged, format),
914
1028
  resolvedOptions.outputMimeType
915
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
+ });
916
1041
  }).catch((error) => {
917
1042
  if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
918
1043
  throw error;
@@ -920,13 +1045,28 @@ function mergeSynthesisResults(results, options) {
920
1045
  });
921
1046
  }
922
1047
  try {
923
- return createMergedResult(
1048
+ const result = createMergedResult(
924
1049
  results,
925
1050
  mergeAudioBuffers(buffers, { format }),
926
1051
  format,
927
1052
  inputSpecs[0],
928
1053
  resolvedOptions.outputMimeType
929
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;
930
1070
  } catch (error) {
931
1071
  if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
932
1072
  throw error;
@@ -948,8 +1088,52 @@ var ChunkValidationError = class extends Error {
948
1088
  this.diagnostics = diagnostics;
949
1089
  }
950
1090
  };
951
- function failure(error) {
952
- 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
+ };
953
1137
  }
954
1138
  function isRetryable(error) {
955
1139
  if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
@@ -964,6 +1148,9 @@ function delayForRetry(options, attempt) {
964
1148
  const base = Math.min(maxDelay, Math.max(0, options.initialDelayMs) * 2 ** Math.max(0, attempt - 1));
965
1149
  return Math.floor(Math.random() * (base + 1));
966
1150
  }
1151
+ function retryDelayForError(options, attempt, error) {
1152
+ return getRetryAfterDelayMs(error) ?? delayForRetry(options, attempt);
1153
+ }
967
1154
  function resolveConcurrency2(value, total) {
968
1155
  if (value === void 0) return 1;
969
1156
  if (value === Infinity) return Math.max(1, total);
@@ -973,7 +1160,8 @@ async function retryableSynthesis(synthesize, options, signal, onRetry) {
973
1160
  const retry = options ? {
974
1161
  maxRetries: Math.max(0, Math.floor(options.maxRetries)),
975
1162
  initialDelayMs: options.initialDelayMs,
976
- maxDelayMs: options.maxDelayMs
1163
+ maxDelayMs: options.maxDelayMs,
1164
+ shouldRetry: options.shouldRetry
977
1165
  } : void 0;
978
1166
  let attempt = 0;
979
1167
  while (true) {
@@ -981,9 +1169,10 @@ async function retryableSynthesis(synthesize, options, signal, onRetry) {
981
1169
  try {
982
1170
  return await synthesize();
983
1171
  } catch (error) {
984
- if (!retry || attempt >= retry.maxRetries || !isRetryable(error)) throw error;
1172
+ if (!retry || attempt >= retry.maxRetries || !(retry.shouldRetry?.(error, attempt + 1) ?? isRetryable(error)))
1173
+ throw error;
985
1174
  attempt += 1;
986
- const delayMs = delayForRetry(retry, attempt);
1175
+ const delayMs = retryDelayForError(retry, attempt, error);
987
1176
  onRetry(attempt, delayMs);
988
1177
  if (delayMs > 0)
989
1178
  await new Promise((resolve, reject) => {
@@ -1007,7 +1196,7 @@ function sharedValidationOptions(options, signal) {
1007
1196
  const runner = (0, import_ssml_core2.createAzureUrlValidatorRunner)(validator, {
1008
1197
  ...options.urlValidation ?? {},
1009
1198
  ...options.urlValidatorConcurrency !== void 0 ? { concurrency: options.urlValidatorConcurrency } : {},
1010
- ...options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
1199
+ ...options.timeouts?.urlValidationMs !== void 0 ? { timeoutMs: options.timeouts.urlValidationMs } : options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
1011
1200
  ...signal ? { signal } : {},
1012
1201
  ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
1013
1202
  });
@@ -1036,7 +1225,11 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
1036
1225
  ok: true,
1037
1226
  success: true,
1038
1227
  status: "success",
1039
- 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
+ })
1040
1233
  };
1041
1234
  } catch (error) {
1042
1235
  const synthesisError = toSynthesisError(error);
@@ -1044,7 +1237,10 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
1044
1237
  }
1045
1238
  }
1046
1239
  async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1047
- const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
1240
+ const validationOptions = sharedValidationOptions(
1241
+ { ...options.validation ?? options, timeouts: options.timeouts },
1242
+ options.signal
1243
+ );
1048
1244
  if (options.signal?.aborted) {
1049
1245
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
1050
1246
  return failure(error);
@@ -1078,16 +1274,17 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1078
1274
  return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
1079
1275
  })
1080
1276
  );
1081
- const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
1082
1277
  if (options.signal?.aborted) {
1083
1278
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
1084
1279
  return failure(error);
1085
1280
  }
1086
- if (firstInvalidIndex >= 0) {
1087
- const error = new ChunkValidationError(firstInvalidIndex, validations[firstInvalidIndex] ?? []);
1088
- pending(firstInvalidIndex, "failed", error);
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);
1089
1285
  return failure(error);
1090
1286
  }
1287
+ let fallbackJobScope;
1091
1288
  try {
1092
1289
  if (client.synthesizeChunks) {
1093
1290
  const normalizedChunks = chunks.map((chunk) => {
@@ -1099,20 +1296,39 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1099
1296
  outputFormat: options.outputFormat,
1100
1297
  signal: options.signal,
1101
1298
  timeoutMs: options.timeoutMs,
1299
+ timeouts: options.timeouts,
1102
1300
  sourceNodePath: options.sourceNodePath,
1103
1301
  concurrency: options.concurrency,
1104
- retryOptions: options.retryOptions
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
1105
1309
  });
1106
1310
  return { ok: true, success: true, status: "success", value };
1107
1311
  }
1108
1312
  const results = new Array(chunks.length);
1109
- let completed = 0;
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;
1110
1324
  let nextIndex = 0;
1111
1325
  const concurrency = resolveConcurrency2(options.concurrency, chunks.length);
1112
1326
  const worker = async () => {
1113
1327
  while (true) {
1114
1328
  const index = nextIndex++;
1115
1329
  if (index >= chunks.length) return;
1330
+ if (!shouldSynthesize(index)) continue;
1331
+ if (failedIndices.size > 0 && options.cancelOnFailure !== false) return;
1116
1332
  const chunk = chunks[index];
1117
1333
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
1118
1334
  const sourceNodePath = input.sourceNodePath;
@@ -1120,28 +1336,40 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1120
1336
  pending(index, "synthesizing");
1121
1337
  const startedAt = Date.now();
1122
1338
  try {
1123
- const result = await retryableSynthesis(
1124
- () => client.synthesizeSsml(input.ssml, {
1125
- outputFormat: options.outputFormat,
1126
- signal: options.signal,
1127
- timeoutMs: options.timeoutMs,
1128
- sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
1129
- }),
1130
- options.retryOptions,
1131
- options.signal,
1132
- (retryAttempt, nextRetryDelayMs) => options.onProgress?.({
1133
- currentChunk: completed,
1134
- totalChunks: chunks.length,
1135
- percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1136
- chunkIndex: index,
1137
- originalTextRange: input.originalTextRange,
1138
- status: "synthesizing",
1139
- durationMs: Date.now() - startedAt,
1140
- retryAttempt,
1141
- nextRetryDelayMs,
1142
- isRetrying: true
1143
- })
1144
- );
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
+ }
1145
1373
  results[index] = {
1146
1374
  ...result,
1147
1375
  ...input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {},
@@ -1196,6 +1424,7 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1196
1424
  durationMs: Date.now() - startedAt
1197
1425
  });
1198
1426
  } catch (error) {
1427
+ failedIndices.add(index);
1199
1428
  options.onProgress?.({
1200
1429
  currentChunk: completed,
1201
1430
  totalChunks: chunks.length,
@@ -1206,24 +1435,42 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1206
1435
  durationMs: Date.now() - startedAt,
1207
1436
  error
1208
1437
  });
1209
- throw error;
1438
+ if (options.cancelOnFailure !== false) jobScope?.abort();
1439
+ firstError ?? (firstError = error);
1440
+ return;
1210
1441
  }
1211
1442
  }
1212
1443
  };
1213
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;
1455
+ }
1214
1456
  const orderedResults = results.filter((result) => result !== void 0);
1215
1457
  return {
1216
1458
  ok: true,
1217
1459
  success: true,
1218
1460
  status: "success",
1219
- value: mergeSynthesisResults(orderedResults, {
1461
+ value: await mergeSynthesisResults(orderedResults, {
1220
1462
  format: options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3",
1221
- signal: options.signal
1463
+ signal: jobScope?.signal ?? options.signal,
1464
+ customMerger: options.customMerger,
1465
+ outputMimeType: options.outputMimeType,
1466
+ postMergeValidator: options.postMergeValidator
1222
1467
  })
1223
1468
  };
1224
1469
  } catch (error) {
1225
1470
  const synthesisError = toSynthesisError(error);
1226
- return failure(synthesisError);
1471
+ return failure(synthesisError, partialResultFrom(error));
1472
+ } finally {
1473
+ fallbackJobScope?.dispose();
1227
1474
  }
1228
1475
  }
1229
1476
  function withValidationSignal(options, signal) {
@@ -1244,14 +1491,14 @@ var AzureTtsClient = class {
1244
1491
  __privateSet(this, _options, options);
1245
1492
  }
1246
1493
  async synthesize(ssml) {
1247
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1494
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
1248
1495
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
1249
1496
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
1250
- const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs };
1497
+ const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts };
1251
1498
  return synthesizeSpeech(ssml, config);
1252
1499
  }
1253
1500
  async synthesizeSsml(ssml, options = {}) {
1254
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1501
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
1255
1502
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
1256
1503
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
1257
1504
  return synthesizeSsml(ssml, {
@@ -1261,13 +1508,14 @@ var AzureTtsClient = class {
1261
1508
  outputFormat: options.outputFormat ?? outputFormat,
1262
1509
  signal: options.signal ?? signal,
1263
1510
  timeoutMs: options.timeoutMs ?? timeoutMs,
1511
+ timeouts: options.timeouts ?? timeouts,
1264
1512
  sourceNodePath: options.sourceNodePath,
1265
1513
  sourceTextSegments: options.sourceTextSegments,
1266
1514
  sourceMarkers: options.sourceMarkers
1267
1515
  });
1268
1516
  }
1269
1517
  async synthesizeChunks(chunks, options = {}) {
1270
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1518
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
1271
1519
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
1272
1520
  return synthesizeSsmlChunks(chunks, {
1273
1521
  endpoint,
@@ -1276,10 +1524,17 @@ var AzureTtsClient = class {
1276
1524
  outputFormat: options.outputFormat ?? outputFormat,
1277
1525
  signal: options.signal ?? signal,
1278
1526
  timeoutMs: options.timeoutMs ?? timeoutMs,
1527
+ timeouts: options.timeouts ?? timeouts,
1279
1528
  sourceNodePath: options.sourceNodePath,
1280
1529
  onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1281
1530
  concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1282
- retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
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
1283
1538
  });
1284
1539
  }
1285
1540
  async synthesizeSsmlSafe(ssml, options = {}) {
@@ -1291,6 +1546,7 @@ var AzureTtsClient = class {
1291
1546
  outputFormat: options.outputFormat ?? __privateGet(this, _options).outputFormat,
1292
1547
  signal: options.signal ?? __privateGet(this, _options).signal,
1293
1548
  timeoutMs: options.timeoutMs ?? __privateGet(this, _options).timeoutMs,
1549
+ timeouts: options.timeouts ?? __privateGet(this, _options).timeouts,
1294
1550
  onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1295
1551
  concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1296
1552
  retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
@@ -1384,7 +1640,9 @@ async function fetchAzureVoiceCatalog(options) {
1384
1640
  voiceCount: sortedVoices.length,
1385
1641
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1386
1642
  apiVersion: AZURE_VOICE_API_VERSION,
1387
- regions
1643
+ regions,
1644
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1e3).toISOString(),
1645
+ regionDiffs: {}
1388
1646
  }
1389
1647
  };
1390
1648
  }
@@ -1394,6 +1652,7 @@ async function fetchAzureVoiceCatalog(options) {
1394
1652
  AzureTtsClient,
1395
1653
  AzureTtsError,
1396
1654
  AzureTtsSdkError,
1655
+ BatchChunkValidationError,
1397
1656
  ChunkValidationError,
1398
1657
  DEFAULT_OUTPUT_FORMAT,
1399
1658
  MergeError,
@@ -1402,6 +1661,7 @@ async function fetchAzureVoiceCatalog(options) {
1402
1661
  UnsupportedMergeFormatError,
1403
1662
  canMergeAudioFormat,
1404
1663
  fetchAzureVoiceCatalog,
1664
+ getRetryAfterDelayMs,
1405
1665
  inspectAudioSpecification,
1406
1666
  mergeAudioBuffers,
1407
1667
  mergeSynthesisResults,