@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.mjs CHANGED
@@ -8,7 +8,7 @@ var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "
8
8
 
9
9
  // src/errors.ts
10
10
  var AzureTtsError = class extends Error {
11
- constructor(status, statusText, responseBody, requestId) {
11
+ constructor(status, statusText, responseBody, requestId, responseHeaders) {
12
12
  super(`Azure TTS request failed: ${status} ${statusText}`);
13
13
  this.kind = "azure-api-error";
14
14
  this.name = "AzureTtsError";
@@ -16,8 +16,37 @@ var AzureTtsError = class extends Error {
16
16
  this.statusText = statusText;
17
17
  this.responseBody = responseBody;
18
18
  this.requestId = requestId;
19
+ const value = responseHeaders instanceof Headers ? responseHeaders.get("retry-after") : responseHeaders?.["retry-after"] ?? responseHeaders?.["Retry-After"];
20
+ const seconds = value ? Number(value.trim()) : NaN;
21
+ const date = value ? Date.parse(value) : NaN;
22
+ if (Number.isFinite(seconds) && seconds >= 0) this.retryAfterMs = seconds * 1e3;
23
+ else if (Number.isFinite(date)) this.retryAfterMs = Math.max(0, date - Date.now());
19
24
  }
20
25
  };
26
+ function getRetryAfterDelayMs(error) {
27
+ if (error instanceof AzureTtsError && error.retryAfterMs !== void 0) return error.retryAfterMs;
28
+ if (!error || typeof error !== "object") return void 0;
29
+ const candidate = error;
30
+ if (typeof candidate.retryAfterMs === "number" && candidate.retryAfterMs >= 0) return candidate.retryAfterMs;
31
+ const headers = candidate.headers ?? candidate.response?.headers;
32
+ if (headers instanceof Headers) {
33
+ const value = headers.get("retry-after");
34
+ if (!value) return void 0;
35
+ const seconds = Number(value.trim());
36
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
37
+ const date = Date.parse(value);
38
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : void 0;
39
+ }
40
+ if (headers && typeof headers === "object") {
41
+ const value = headers["retry-after"] ?? headers["Retry-After"];
42
+ if (typeof value !== "string") return void 0;
43
+ const seconds = Number(value.trim());
44
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
45
+ const date = Date.parse(value);
46
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : void 0;
47
+ }
48
+ return void 0;
49
+ }
21
50
  var AzureTtsSdkError = class extends AzureTtsError {
22
51
  constructor(errorDetails) {
23
52
  super(0, "Speech SDK", errorDetails, null);
@@ -210,6 +239,8 @@ function formatAudioSpecification(format) {
210
239
  const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
211
240
  const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1e3 : void 0;
212
241
  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";
242
+ const bitDepthMatch = /(\d+)bit/i.exec(format);
243
+ 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;
213
244
  return {
214
245
  format,
215
246
  mimeType: resolveMimeType(format),
@@ -217,6 +248,9 @@ function formatAudioSpecification(format) {
217
248
  sampleRate,
218
249
  channels,
219
250
  ...bitrate ? { bitrate } : {},
251
+ ...bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {},
252
+ ...container ? { container } : {},
253
+ isVbr: /vbr/i.test(format),
220
254
  isCompressed: codec === "mp3" || codec === "opus" || codec === "silk"
221
255
  };
222
256
  }
@@ -252,6 +286,8 @@ function parseMp3Specification(buffer, format) {
252
286
  sampleRate,
253
287
  channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
254
288
  bitrate: bitrateKbps * 1e3,
289
+ container: "mp3-raw",
290
+ isVbr: false,
255
291
  isCompressed: true
256
292
  };
257
293
  }
@@ -273,6 +309,9 @@ function inspectAudioSpecification(buffer, format) {
273
309
  sampleRate,
274
310
  channels,
275
311
  ...sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {},
312
+ bitDepth: bitsPerSample,
313
+ container: "riff-wave",
314
+ isVbr: false,
276
315
  isCompressed: formatCode !== 1
277
316
  };
278
317
  }
@@ -283,7 +322,7 @@ function validateAudioSpecifications(specs) {
283
322
  const first = specs[0];
284
323
  if (!first) return;
285
324
  const mismatch = specs.find(
286
- (spec) => spec.sampleRate !== first.sampleRate || spec.channels !== first.channels || first.bitrate !== void 0 && spec.bitrate !== void 0 && spec.bitrate !== first.bitrate
325
+ (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
287
326
  );
288
327
  if (mismatch)
289
328
  throw new AudioFormatMismatchError(
@@ -486,9 +525,7 @@ async function synthesizeSsml(ssml, config) {
486
525
  };
487
526
  }
488
527
  if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) {
489
- const unmapped = { mappingStatus: "unmapped" };
490
- Object.defineProperty(unmapped, "mappingStatus", { value: "unmapped", enumerable: false });
491
- return unmapped;
528
+ return { mappingStatus: "unmapped" };
492
529
  }
493
530
  const value = text ?? "";
494
531
  let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? offsetHint : -1;
@@ -570,8 +607,13 @@ async function synthesizeSsml(ssml, config) {
570
607
  ...config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
571
608
  ...requestId ? { requestId } : {}
572
609
  };
573
- if (event.mappingStatus === "unmapped")
610
+ if (event.mappingStatus === "unmapped") {
574
611
  Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
612
+ Object.defineProperty(mapped, "toJSON", {
613
+ value: () => ({ ...mapped, mappingStatus: "unmapped" }),
614
+ enumerable: false
615
+ });
616
+ }
575
617
  return mapped;
576
618
  };
577
619
  const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
@@ -594,10 +636,11 @@ async function synthesizeSsml(ssml, config) {
594
636
  abortHandler = () => rejectWithError(new SynthesisCancelledError());
595
637
  config.signal.addEventListener("abort", abortHandler, { once: true });
596
638
  }
597
- if (config.timeoutMs !== void 0 && config.timeoutMs > 0) {
639
+ const timeoutMs = config.timeouts?.perChunkMs ?? config.timeoutMs;
640
+ if (timeoutMs !== void 0 && timeoutMs > 0) {
598
641
  timeout = setTimeout(
599
- () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeoutMs} ms.`)),
600
- config.timeoutMs
642
+ () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
643
+ timeoutMs
601
644
  );
602
645
  }
603
646
  synthesizer.speakSsmlAsync(ssml, cb, rejectWithError);
@@ -616,7 +659,9 @@ function isRetryableSynthesisError(error) {
616
659
  if (typeof status === "number") return status === 429 || status >= 500 && status < 600;
617
660
  return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
618
661
  }
619
- function retryDelay(options, retryAttempt) {
662
+ function retryDelay(options, retryAttempt, error) {
663
+ const retryAfterMs = getRetryAfterDelayMs(error);
664
+ if (retryAfterMs !== void 0) return retryAfterMs;
620
665
  const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
621
666
  return Math.floor(Math.random() * (base + 1));
622
667
  }
@@ -648,7 +693,8 @@ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
648
693
  const options = retryOptions ? {
649
694
  maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
650
695
  initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
651
- maxDelayMs: Math.max(0, retryOptions.maxDelayMs)
696
+ maxDelayMs: Math.max(0, retryOptions.maxDelayMs),
697
+ shouldRetry: retryOptions.shouldRetry
652
698
  } : void 0;
653
699
  let attempt = 0;
654
700
  while (true) {
@@ -656,17 +702,56 @@ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
656
702
  try {
657
703
  return await synthesizeSsml(ssml, config);
658
704
  } catch (error) {
659
- if (!options || attempt >= options.maxRetries || !isRetryableSynthesisError(error)) throw error;
705
+ if (!options || attempt >= options.maxRetries || !(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error)))
706
+ throw error;
660
707
  attempt += 1;
661
- const delayMs = retryDelay(options, attempt);
708
+ const delayMs = retryDelay(options, attempt, error);
662
709
  onRetry(attempt, delayMs);
663
710
  await waitForRetry(delayMs, config.signal);
664
711
  }
665
712
  }
666
713
  }
714
+ function createAbortScope(parent, timeoutMs) {
715
+ const controller = new AbortController();
716
+ let didTimeout = false;
717
+ const onAbort = () => controller.abort();
718
+ if (parent?.aborted) controller.abort();
719
+ parent?.addEventListener("abort", onAbort, { once: true });
720
+ const timer = timeoutMs !== void 0 && timeoutMs > 0 ? setTimeout(() => {
721
+ didTimeout = true;
722
+ controller.abort();
723
+ }, timeoutMs) : void 0;
724
+ return {
725
+ signal: controller.signal,
726
+ timedOut: () => didTimeout,
727
+ dispose: () => {
728
+ if (timer) clearTimeout(timer);
729
+ parent?.removeEventListener("abort", onAbort);
730
+ },
731
+ abort: () => controller.abort()
732
+ };
733
+ }
734
+ async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs, onRetry) {
735
+ const scope = createAbortScope(config.signal, timeoutMs);
736
+ try {
737
+ return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry);
738
+ } catch (error) {
739
+ if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
740
+ throw error;
741
+ } finally {
742
+ scope.dispose();
743
+ }
744
+ }
667
745
  async function synthesizeSsmlChunks(chunks, config) {
668
746
  const results = new Array(chunks.length);
669
747
  const totalChunks = chunks.length;
748
+ const cachedChunks = new Map((config.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
749
+ for (const [index, cached] of cachedChunks) {
750
+ if (index >= 0 && index < totalChunks) results[index] = cached;
751
+ }
752
+ const requestedIndices = config.resumeChunkIndices ? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks)) : void 0;
753
+ const shouldSynthesize = (index) => !cachedChunks.has(index) && (requestedIndices === void 0 || requestedIndices.has(index));
754
+ const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
670
755
  const report = (event) => config.onProgress?.(event);
671
756
  for (const [index, chunk] of chunks.entries()) {
672
757
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
@@ -680,13 +765,17 @@ async function synthesizeSsmlChunks(chunks, config) {
680
765
  durationMs: 0
681
766
  });
682
767
  }
683
- let completed = 0;
768
+ let completed = [...results].filter((result) => result !== void 0).length;
684
769
  let nextIndex = 0;
685
770
  const concurrency = resolveConcurrency(config.concurrency, chunks.length);
771
+ let firstError;
772
+ const failedIndices = /* @__PURE__ */ new Set();
686
773
  const worker = async () => {
687
774
  while (true) {
688
775
  const index = nextIndex++;
689
776
  if (index >= chunks.length) return;
777
+ if (!shouldSynthesize(index)) continue;
778
+ if (firstError && config.cancelOnFailure !== false) return;
690
779
  const chunk = chunks[index];
691
780
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
692
781
  report({
@@ -700,10 +789,11 @@ async function synthesizeSsmlChunks(chunks, config) {
700
789
  });
701
790
  const startedAt = Date.now();
702
791
  try {
703
- const result = await synthesizeWithRetry(
792
+ const result = await synthesizeChunkWithTimeout(
704
793
  input.ssml,
705
794
  {
706
795
  ...config,
796
+ signal: scope.signal,
707
797
  ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
708
798
  ...input.sourceNodePath ?? config.sourceNodePath ? { sourceNodePath: [...input.sourceNodePath ?? config.sourceNodePath ?? []] } : {},
709
799
  ...input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {},
@@ -712,6 +802,7 @@ async function synthesizeSsmlChunks(chunks, config) {
712
802
  onProgress: void 0
713
803
  },
714
804
  config.retryOptions,
805
+ config.timeouts?.chunkWithRetriesMs ?? config.timeouts?.perChunkMs ?? config.timeoutMs,
715
806
  (retryAttempt, nextRetryDelayMs) => report({
716
807
  currentChunk: completed,
717
808
  totalChunks,
@@ -737,6 +828,7 @@ async function synthesizeSsmlChunks(chunks, config) {
737
828
  durationMs: Date.now() - startedAt
738
829
  });
739
830
  } catch (error) {
831
+ failedIndices.add(index);
740
832
  report({
741
833
  currentChunk: completed,
742
834
  totalChunks,
@@ -747,16 +839,36 @@ async function synthesizeSsmlChunks(chunks, config) {
747
839
  durationMs: Date.now() - startedAt,
748
840
  error
749
841
  });
750
- throw error;
842
+ firstError ?? (firstError = scope.timedOut() ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`) : error);
843
+ if (config.cancelOnFailure !== false) scope.abort();
844
+ return;
751
845
  }
752
846
  }
753
847
  };
754
- await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
755
- const orderedResults = results.filter((result) => result !== void 0);
756
- return mergeSynthesisResults(orderedResults, {
757
- format: config.outputFormat ?? DEFAULT_OUTPUT_FORMAT,
758
- signal: config.signal
759
- });
848
+ try {
849
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
850
+ if (firstError) throw firstError;
851
+ const orderedResults = results.filter((result) => result !== void 0);
852
+ return await mergeSynthesisResults(orderedResults, {
853
+ format: config.outputFormat ?? DEFAULT_OUTPUT_FORMAT,
854
+ signal: scope.signal,
855
+ customMerger: config.customMerger,
856
+ outputMimeType: config.outputMimeType,
857
+ postMergeValidator: config.postMergeValidator
858
+ });
859
+ } catch (error) {
860
+ const partial = {
861
+ synthesizedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
862
+ completedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
863
+ pendingChunkIndices: chunks.flatMap((_chunk, chunkIndex) => results[chunkIndex] ? [] : [chunkIndex]),
864
+ failedChunkIndices: [...failedIndices],
865
+ totalChunks
866
+ };
867
+ if (error && typeof error === "object") error.partialResult = partial;
868
+ throw error;
869
+ } finally {
870
+ scope.dispose();
871
+ }
760
872
  }
761
873
  function createMergedResult(results, audioData, format, audioSpec, outputMimeType) {
762
874
  const boundaries = [];
@@ -850,13 +962,24 @@ function mergeSynthesisResults(results, options) {
850
962
  if (!(merged instanceof ArrayBuffer) || buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
851
963
  throw new MergeError("The custom audio merger returned an invalid audio buffer.");
852
964
  if (signal.aborted) throw new SynthesisCancelledError();
853
- return createMergedResult(
965
+ const result = createMergedResult(
854
966
  results,
855
967
  merged,
856
968
  format,
857
969
  inspectAudioSpecification(merged, format),
858
970
  resolvedOptions.outputMimeType
859
971
  );
972
+ return Promise.resolve(
973
+ resolvedOptions.postMergeValidator?.(result, {
974
+ format,
975
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
976
+ inputSpecs,
977
+ signal
978
+ })
979
+ ).then((valid) => {
980
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
981
+ return result;
982
+ });
860
983
  }).catch((error) => {
861
984
  if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
862
985
  throw error;
@@ -864,13 +987,28 @@ function mergeSynthesisResults(results, options) {
864
987
  });
865
988
  }
866
989
  try {
867
- return createMergedResult(
990
+ const result = createMergedResult(
868
991
  results,
869
992
  mergeAudioBuffers(buffers, { format }),
870
993
  format,
871
994
  inputSpecs[0],
872
995
  resolvedOptions.outputMimeType
873
996
  );
997
+ if (resolvedOptions.postMergeValidator) {
998
+ const validation = resolvedOptions.postMergeValidator(result, {
999
+ format,
1000
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
1001
+ inputSpecs,
1002
+ signal
1003
+ });
1004
+ if (validation instanceof Promise)
1005
+ return validation.then((valid) => {
1006
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1007
+ return result;
1008
+ });
1009
+ if (validation === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1010
+ }
1011
+ return result;
874
1012
  } catch (error) {
875
1013
  if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
876
1014
  throw error;
@@ -895,8 +1033,52 @@ var ChunkValidationError = class extends Error {
895
1033
  this.diagnostics = diagnostics;
896
1034
  }
897
1035
  };
898
- function failure(error) {
899
- return { ok: false, success: false, status: error.kind, error };
1036
+ var BatchChunkValidationError = class extends ChunkValidationError {
1037
+ constructor(chunkDiagnostics) {
1038
+ const first = chunkDiagnostics[0];
1039
+ super(first?.chunkIndex ?? -1, first?.diagnostics ?? []);
1040
+ this.name = "BatchChunkValidationError";
1041
+ this.message = `SSML validation failed for ${chunkDiagnostics.length} chunk(s); the Azure Speech API was not called.`;
1042
+ this.chunkDiagnostics = chunkDiagnostics;
1043
+ this.totalErrorCount = chunkDiagnostics.reduce((total, chunk) => total + chunk.diagnostics.length, 0);
1044
+ this.errorCount = this.totalErrorCount;
1045
+ this.totalErrors = this.totalErrorCount;
1046
+ }
1047
+ };
1048
+ function failure(error, partialResult) {
1049
+ return {
1050
+ ok: false,
1051
+ success: false,
1052
+ status: error.kind,
1053
+ error,
1054
+ ...partialResult ? { partialResult } : {}
1055
+ };
1056
+ }
1057
+ function partialResultFrom(error) {
1058
+ if (!error || typeof error !== "object") return void 0;
1059
+ const partial = error.partialResult;
1060
+ if (!partial || typeof partial !== "object") return void 0;
1061
+ return partial;
1062
+ }
1063
+ function createSafeAbortScope(parent, timeoutMs) {
1064
+ const controller = new AbortController();
1065
+ let didTimeout = false;
1066
+ const onAbort = () => controller.abort();
1067
+ if (parent?.aborted) controller.abort();
1068
+ parent?.addEventListener("abort", onAbort, { once: true });
1069
+ const timer = timeoutMs !== void 0 && timeoutMs > 0 ? setTimeout(() => {
1070
+ didTimeout = true;
1071
+ controller.abort();
1072
+ }, timeoutMs) : void 0;
1073
+ return {
1074
+ signal: controller.signal,
1075
+ timedOut: () => didTimeout,
1076
+ dispose: () => {
1077
+ if (timer) clearTimeout(timer);
1078
+ parent?.removeEventListener("abort", onAbort);
1079
+ },
1080
+ abort: () => controller.abort()
1081
+ };
900
1082
  }
901
1083
  function isRetryable(error) {
902
1084
  if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
@@ -911,6 +1093,9 @@ function delayForRetry(options, attempt) {
911
1093
  const base = Math.min(maxDelay, Math.max(0, options.initialDelayMs) * 2 ** Math.max(0, attempt - 1));
912
1094
  return Math.floor(Math.random() * (base + 1));
913
1095
  }
1096
+ function retryDelayForError(options, attempt, error) {
1097
+ return getRetryAfterDelayMs(error) ?? delayForRetry(options, attempt);
1098
+ }
914
1099
  function resolveConcurrency2(value, total) {
915
1100
  if (value === void 0) return 1;
916
1101
  if (value === Infinity) return Math.max(1, total);
@@ -920,7 +1105,8 @@ async function retryableSynthesis(synthesize, options, signal, onRetry) {
920
1105
  const retry = options ? {
921
1106
  maxRetries: Math.max(0, Math.floor(options.maxRetries)),
922
1107
  initialDelayMs: options.initialDelayMs,
923
- maxDelayMs: options.maxDelayMs
1108
+ maxDelayMs: options.maxDelayMs,
1109
+ shouldRetry: options.shouldRetry
924
1110
  } : void 0;
925
1111
  let attempt = 0;
926
1112
  while (true) {
@@ -928,9 +1114,10 @@ async function retryableSynthesis(synthesize, options, signal, onRetry) {
928
1114
  try {
929
1115
  return await synthesize();
930
1116
  } catch (error) {
931
- if (!retry || attempt >= retry.maxRetries || !isRetryable(error)) throw error;
1117
+ if (!retry || attempt >= retry.maxRetries || !(retry.shouldRetry?.(error, attempt + 1) ?? isRetryable(error)))
1118
+ throw error;
932
1119
  attempt += 1;
933
- const delayMs = delayForRetry(retry, attempt);
1120
+ const delayMs = retryDelayForError(retry, attempt, error);
934
1121
  onRetry(attempt, delayMs);
935
1122
  if (delayMs > 0)
936
1123
  await new Promise((resolve, reject) => {
@@ -954,7 +1141,7 @@ function sharedValidationOptions(options, signal) {
954
1141
  const runner = createAzureUrlValidatorRunner(validator, {
955
1142
  ...options.urlValidation ?? {},
956
1143
  ...options.urlValidatorConcurrency !== void 0 ? { concurrency: options.urlValidatorConcurrency } : {},
957
- ...options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
1144
+ ...options.timeouts?.urlValidationMs !== void 0 ? { timeoutMs: options.timeouts.urlValidationMs } : options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
958
1145
  ...signal ? { signal } : {},
959
1146
  ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
960
1147
  });
@@ -983,7 +1170,11 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
983
1170
  ok: true,
984
1171
  success: true,
985
1172
  status: "success",
986
- value: await client.synthesizeSsml(ssml, { signal: options.signal })
1173
+ value: await client.synthesizeSsml(ssml, {
1174
+ signal: options.signal,
1175
+ timeoutMs: options.timeouts?.perChunkMs,
1176
+ timeouts: options.timeouts
1177
+ })
987
1178
  };
988
1179
  } catch (error) {
989
1180
  const synthesisError = toSynthesisError(error);
@@ -991,7 +1182,10 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
991
1182
  }
992
1183
  }
993
1184
  async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
994
- const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
1185
+ const validationOptions = sharedValidationOptions(
1186
+ { ...options.validation ?? options, timeouts: options.timeouts },
1187
+ options.signal
1188
+ );
995
1189
  if (options.signal?.aborted) {
996
1190
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
997
1191
  return failure(error);
@@ -1025,16 +1219,17 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1025
1219
  return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
1026
1220
  })
1027
1221
  );
1028
- const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
1029
1222
  if (options.signal?.aborted) {
1030
1223
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
1031
1224
  return failure(error);
1032
1225
  }
1033
- if (firstInvalidIndex >= 0) {
1034
- const error = new ChunkValidationError(firstInvalidIndex, validations[firstInvalidIndex] ?? []);
1035
- pending(firstInvalidIndex, "failed", error);
1226
+ const chunkDiagnostics = validations.map((diagnostics, chunkIndex) => ({ chunkIndex, diagnostics })).filter((entry) => entry.diagnostics.length > 0);
1227
+ if (chunkDiagnostics.length > 0) {
1228
+ const error = new BatchChunkValidationError(chunkDiagnostics);
1229
+ for (const entry of chunkDiagnostics) pending(entry.chunkIndex, "failed", error);
1036
1230
  return failure(error);
1037
1231
  }
1232
+ let fallbackJobScope;
1038
1233
  try {
1039
1234
  if (client.synthesizeChunks) {
1040
1235
  const normalizedChunks = chunks.map((chunk) => {
@@ -1046,20 +1241,39 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1046
1241
  outputFormat: options.outputFormat,
1047
1242
  signal: options.signal,
1048
1243
  timeoutMs: options.timeoutMs,
1244
+ timeouts: options.timeouts,
1049
1245
  sourceNodePath: options.sourceNodePath,
1050
1246
  concurrency: options.concurrency,
1051
- retryOptions: options.retryOptions
1247
+ retryOptions: options.retryOptions,
1248
+ cancelOnFailure: options.cancelOnFailure,
1249
+ resumeChunks: options.resumeChunks,
1250
+ resumeChunkIndices: options.resumeChunkIndices,
1251
+ customMerger: options.customMerger,
1252
+ outputMimeType: options.outputMimeType,
1253
+ postMergeValidator: options.postMergeValidator
1052
1254
  });
1053
1255
  return { ok: true, success: true, status: "success", value };
1054
1256
  }
1055
1257
  const results = new Array(chunks.length);
1056
- let completed = 0;
1258
+ const cachedChunks = new Map((options.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
1259
+ for (const [index, cached] of cachedChunks) {
1260
+ if (index >= 0 && index < chunks.length) results[index] = cached;
1261
+ }
1262
+ const requestedIndices = options.resumeChunkIndices ? new Set(options.resumeChunkIndices.filter((index) => index >= 0 && index < chunks.length)) : void 0;
1263
+ const shouldSynthesize = (index) => !cachedChunks.has(index) && (requestedIndices === void 0 || requestedIndices.has(index));
1264
+ const jobScope = chunks.length > 1 || options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs) : void 0;
1265
+ fallbackJobScope = jobScope;
1266
+ const failedIndices = /* @__PURE__ */ new Set();
1267
+ let firstError;
1268
+ let completed = [...results].filter((result) => result !== void 0).length;
1057
1269
  let nextIndex = 0;
1058
1270
  const concurrency = resolveConcurrency2(options.concurrency, chunks.length);
1059
1271
  const worker = async () => {
1060
1272
  while (true) {
1061
1273
  const index = nextIndex++;
1062
1274
  if (index >= chunks.length) return;
1275
+ if (!shouldSynthesize(index)) continue;
1276
+ if (failedIndices.size > 0 && options.cancelOnFailure !== false) return;
1063
1277
  const chunk = chunks[index];
1064
1278
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
1065
1279
  const sourceNodePath = input.sourceNodePath;
@@ -1067,28 +1281,40 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1067
1281
  pending(index, "synthesizing");
1068
1282
  const startedAt = Date.now();
1069
1283
  try {
1070
- const result = await retryableSynthesis(
1071
- () => client.synthesizeSsml(input.ssml, {
1072
- outputFormat: options.outputFormat,
1073
- signal: options.signal,
1074
- timeoutMs: options.timeoutMs,
1075
- sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
1076
- }),
1077
- options.retryOptions,
1078
- options.signal,
1079
- (retryAttempt, nextRetryDelayMs) => options.onProgress?.({
1080
- currentChunk: completed,
1081
- totalChunks: chunks.length,
1082
- percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1083
- chunkIndex: index,
1084
- originalTextRange: input.originalTextRange,
1085
- status: "synthesizing",
1086
- durationMs: Date.now() - startedAt,
1087
- retryAttempt,
1088
- nextRetryDelayMs,
1089
- isRetrying: true
1090
- })
1091
- );
1284
+ const chunkTimeout = options.timeouts?.chunkWithRetriesMs ?? options.timeouts?.perChunkMs;
1285
+ const chunkScope = chunkTimeout !== void 0 || jobScope ? createSafeAbortScope(jobScope?.signal ?? options.signal, chunkTimeout ?? options.timeoutMs) : void 0;
1286
+ const chunkSignal = chunkScope?.signal ?? options.signal;
1287
+ let result;
1288
+ try {
1289
+ result = await retryableSynthesis(
1290
+ () => client.synthesizeSsml(input.ssml, {
1291
+ outputFormat: options.outputFormat,
1292
+ signal: chunkSignal,
1293
+ timeoutMs: options.timeouts?.perChunkMs ?? options.timeoutMs,
1294
+ sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
1295
+ }),
1296
+ options.retryOptions,
1297
+ chunkSignal,
1298
+ (retryAttempt, nextRetryDelayMs) => options.onProgress?.({
1299
+ currentChunk: completed,
1300
+ totalChunks: chunks.length,
1301
+ percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1302
+ chunkIndex: index,
1303
+ originalTextRange: input.originalTextRange,
1304
+ status: "synthesizing",
1305
+ durationMs: Date.now() - startedAt,
1306
+ retryAttempt,
1307
+ nextRetryDelayMs,
1308
+ isRetrying: true
1309
+ })
1310
+ );
1311
+ } catch (error) {
1312
+ if (chunkScope?.timedOut())
1313
+ throw new Error(`Speech synthesis timed out after ${chunkTimeout ?? options.timeoutMs} ms.`);
1314
+ throw error;
1315
+ } finally {
1316
+ chunkScope?.dispose();
1317
+ }
1092
1318
  results[index] = {
1093
1319
  ...result,
1094
1320
  ...input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {},
@@ -1143,6 +1369,7 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1143
1369
  durationMs: Date.now() - startedAt
1144
1370
  });
1145
1371
  } catch (error) {
1372
+ failedIndices.add(index);
1146
1373
  options.onProgress?.({
1147
1374
  currentChunk: completed,
1148
1375
  totalChunks: chunks.length,
@@ -1153,24 +1380,42 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1153
1380
  durationMs: Date.now() - startedAt,
1154
1381
  error
1155
1382
  });
1156
- throw error;
1383
+ if (options.cancelOnFailure !== false) jobScope?.abort();
1384
+ firstError ?? (firstError = error);
1385
+ return;
1157
1386
  }
1158
1387
  }
1159
1388
  };
1160
1389
  await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
1390
+ if (failedIndices.size > 0) {
1391
+ const error = firstError ?? new Error("One or more SSML chunks failed to synthesize.");
1392
+ error.partialResult = {
1393
+ synthesizedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
1394
+ completedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
1395
+ pendingChunkIndices: chunks.flatMap((_chunk, chunkIndex) => results[chunkIndex] ? [] : [chunkIndex]),
1396
+ failedChunkIndices: [...failedIndices],
1397
+ totalChunks: chunks.length
1398
+ };
1399
+ throw error;
1400
+ }
1161
1401
  const orderedResults = results.filter((result) => result !== void 0);
1162
1402
  return {
1163
1403
  ok: true,
1164
1404
  success: true,
1165
1405
  status: "success",
1166
- value: mergeSynthesisResults(orderedResults, {
1406
+ value: await mergeSynthesisResults(orderedResults, {
1167
1407
  format: options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3",
1168
- signal: options.signal
1408
+ signal: jobScope?.signal ?? options.signal,
1409
+ customMerger: options.customMerger,
1410
+ outputMimeType: options.outputMimeType,
1411
+ postMergeValidator: options.postMergeValidator
1169
1412
  })
1170
1413
  };
1171
1414
  } catch (error) {
1172
1415
  const synthesisError = toSynthesisError(error);
1173
- return failure(synthesisError);
1416
+ return failure(synthesisError, partialResultFrom(error));
1417
+ } finally {
1418
+ fallbackJobScope?.dispose();
1174
1419
  }
1175
1420
  }
1176
1421
  function withValidationSignal(options, signal) {
@@ -1191,14 +1436,14 @@ var AzureTtsClient = class {
1191
1436
  __privateSet(this, _options, options);
1192
1437
  }
1193
1438
  async synthesize(ssml) {
1194
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1439
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
1195
1440
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
1196
1441
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
1197
- const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs };
1442
+ const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts };
1198
1443
  return synthesizeSpeech(ssml, config);
1199
1444
  }
1200
1445
  async synthesizeSsml(ssml, options = {}) {
1201
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1446
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
1202
1447
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
1203
1448
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
1204
1449
  return synthesizeSsml(ssml, {
@@ -1208,13 +1453,14 @@ var AzureTtsClient = class {
1208
1453
  outputFormat: options.outputFormat ?? outputFormat,
1209
1454
  signal: options.signal ?? signal,
1210
1455
  timeoutMs: options.timeoutMs ?? timeoutMs,
1456
+ timeouts: options.timeouts ?? timeouts,
1211
1457
  sourceNodePath: options.sourceNodePath,
1212
1458
  sourceTextSegments: options.sourceTextSegments,
1213
1459
  sourceMarkers: options.sourceMarkers
1214
1460
  });
1215
1461
  }
1216
1462
  async synthesizeChunks(chunks, options = {}) {
1217
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1463
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
1218
1464
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
1219
1465
  return synthesizeSsmlChunks(chunks, {
1220
1466
  endpoint,
@@ -1223,10 +1469,17 @@ var AzureTtsClient = class {
1223
1469
  outputFormat: options.outputFormat ?? outputFormat,
1224
1470
  signal: options.signal ?? signal,
1225
1471
  timeoutMs: options.timeoutMs ?? timeoutMs,
1472
+ timeouts: options.timeouts ?? timeouts,
1226
1473
  sourceNodePath: options.sourceNodePath,
1227
1474
  onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1228
1475
  concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1229
- retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
1476
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
1477
+ cancelOnFailure: options.cancelOnFailure,
1478
+ resumeChunks: options.resumeChunks,
1479
+ resumeChunkIndices: options.resumeChunkIndices,
1480
+ customMerger: options.customMerger,
1481
+ outputMimeType: options.outputMimeType,
1482
+ postMergeValidator: options.postMergeValidator
1230
1483
  });
1231
1484
  }
1232
1485
  async synthesizeSsmlSafe(ssml, options = {}) {
@@ -1238,6 +1491,7 @@ var AzureTtsClient = class {
1238
1491
  outputFormat: options.outputFormat ?? __privateGet(this, _options).outputFormat,
1239
1492
  signal: options.signal ?? __privateGet(this, _options).signal,
1240
1493
  timeoutMs: options.timeoutMs ?? __privateGet(this, _options).timeoutMs,
1494
+ timeouts: options.timeouts ?? __privateGet(this, _options).timeouts,
1241
1495
  onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1242
1496
  concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1243
1497
  retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
@@ -1331,7 +1585,9 @@ async function fetchAzureVoiceCatalog(options) {
1331
1585
  voiceCount: sortedVoices.length,
1332
1586
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1333
1587
  apiVersion: AZURE_VOICE_API_VERSION,
1334
- regions
1588
+ regions,
1589
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1e3).toISOString(),
1590
+ regionDiffs: {}
1335
1591
  }
1336
1592
  };
1337
1593
  }
@@ -1340,6 +1596,7 @@ export {
1340
1596
  AzureTtsClient,
1341
1597
  AzureTtsError,
1342
1598
  AzureTtsSdkError,
1599
+ BatchChunkValidationError,
1343
1600
  ChunkValidationError,
1344
1601
  DEFAULT_OUTPUT_FORMAT,
1345
1602
  MergeError,
@@ -1348,6 +1605,7 @@ export {
1348
1605
  UnsupportedMergeFormatError,
1349
1606
  canMergeAudioFormat,
1350
1607
  fetchAzureVoiceCatalog,
1608
+ getRetryAfterDelayMs,
1351
1609
  inspectAudioSpecification,
1352
1610
  mergeAudioBuffers,
1353
1611
  mergeSynthesisResults,