ssml-builder-js 2.16.0 → 2.18.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(src_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,
@@ -51,6 +52,7 @@ __export(src_exports, {
51
52
  buildPartialSsml: () => buildPartialSsml,
52
53
  buildSsml: () => buildSsml,
53
54
  canMergeAudioFormat: () => canMergeAudioFormat,
55
+ computeChunkFingerprint: () => computeChunkFingerprint,
54
56
  createAzureUrlValidatorRunner: () => createAzureUrlValidatorRunner,
55
57
  extractSsmlText: () => extractSsmlText,
56
58
  extractSsmlTranslatableText: () => extractSsmlTranslatableText,
@@ -58,6 +60,7 @@ __export(src_exports, {
58
60
  fromPlainTextToSsml: () => fromPlainTextToSsml,
59
61
  getAzureVoiceCatalogMetadata: () => getAzureVoiceCatalogMetadata,
60
62
  getBuiltInVoiceCatalogMetadata: () => getBuiltInVoiceCatalogMetadata,
63
+ getRetryAfterDelayMs: () => getRetryAfterDelayMs,
61
64
  getSsmlSourceMap: () => getSsmlSourceMap,
62
65
  inspectAudioSpecification: () => inspectAudioSpecification,
63
66
  isValidAzureAudioDuration: () => isValidAzureAudioDuration,
@@ -2723,7 +2726,9 @@ var AZURE_VOICE_CATALOG_METADATA = {
2723
2726
  apiVersion: "2025-10-01",
2724
2727
  generatedAt: "2026-08-28T00:00:00.000Z",
2725
2728
  regions: [],
2726
- voiceCount: AZURE_VOICE_DEFINITIONS.length
2729
+ voiceCount: AZURE_VOICE_DEFINITIONS.length,
2730
+ expiresAt: "2026-09-04T00:00:00.000Z",
2731
+ regionDiffs: {}
2727
2732
  };
2728
2733
 
2729
2734
  // packages/ssml-core/src/voiceCatalog.ts
@@ -2737,7 +2742,7 @@ var getBuiltInVoiceCatalogMetadata = getAzureVoiceCatalogMetadata;
2737
2742
 
2738
2743
  // packages/azure-tts-client/src/errors.ts
2739
2744
  var AzureTtsError = class extends Error {
2740
- constructor(status, statusText, responseBody, requestId) {
2745
+ constructor(status, statusText, responseBody, requestId, responseHeaders) {
2741
2746
  super(`Azure TTS request failed: ${status} ${statusText}`);
2742
2747
  this.kind = "azure-api-error";
2743
2748
  this.name = "AzureTtsError";
@@ -2745,8 +2750,37 @@ var AzureTtsError = class extends Error {
2745
2750
  this.statusText = statusText;
2746
2751
  this.responseBody = responseBody;
2747
2752
  this.requestId = requestId;
2753
+ const value = responseHeaders instanceof Headers ? responseHeaders.get("retry-after") : responseHeaders?.["retry-after"] ?? responseHeaders?.["Retry-After"];
2754
+ const seconds = value ? Number(value.trim()) : NaN;
2755
+ const date = value ? Date.parse(value) : NaN;
2756
+ if (Number.isFinite(seconds) && seconds >= 0) this.retryAfterMs = seconds * 1e3;
2757
+ else if (Number.isFinite(date)) this.retryAfterMs = Math.max(0, date - Date.now());
2748
2758
  }
2749
2759
  };
2760
+ function getRetryAfterDelayMs(error) {
2761
+ if (error instanceof AzureTtsError && error.retryAfterMs !== void 0) return error.retryAfterMs;
2762
+ if (!error || typeof error !== "object") return void 0;
2763
+ const candidate = error;
2764
+ if (typeof candidate.retryAfterMs === "number" && candidate.retryAfterMs >= 0) return candidate.retryAfterMs;
2765
+ const headers = candidate.headers ?? candidate.response?.headers;
2766
+ if (headers instanceof Headers) {
2767
+ const value = headers.get("retry-after");
2768
+ if (!value) return void 0;
2769
+ const seconds = Number(value.trim());
2770
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
2771
+ const date = Date.parse(value);
2772
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : void 0;
2773
+ }
2774
+ if (headers && typeof headers === "object") {
2775
+ const value = headers["retry-after"] ?? headers["Retry-After"];
2776
+ if (typeof value !== "string") return void 0;
2777
+ const seconds = Number(value.trim());
2778
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
2779
+ const date = Date.parse(value);
2780
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : void 0;
2781
+ }
2782
+ return void 0;
2783
+ }
2750
2784
  var AzureTtsSdkError = class extends AzureTtsError {
2751
2785
  constructor(errorDetails) {
2752
2786
  super(0, "Speech SDK", errorDetails, null);
@@ -4498,7 +4532,9 @@ var AZURE_VOICE_CATALOG_METADATA2 = {
4498
4532
  apiVersion: "2025-10-01",
4499
4533
  generatedAt: "2026-08-28T00:00:00.000Z",
4500
4534
  regions: [],
4501
- voiceCount: AZURE_VOICE_DEFINITIONS2.length
4535
+ voiceCount: AZURE_VOICE_DEFINITIONS2.length,
4536
+ expiresAt: "2026-09-04T00:00:00.000Z",
4537
+ regionDiffs: {}
4502
4538
  };
4503
4539
 
4504
4540
  // packages/azure-tts-client/src/outputFormats.ts
@@ -4548,6 +4584,9 @@ var OUTPUT_FORMATS = {
4548
4584
  function resolveMimeType(outputFormat) {
4549
4585
  if (/(?:wav|wave|riff)/i.test(outputFormat)) return "audio/wav";
4550
4586
  if (/(?:mp3|mpeg)/i.test(outputFormat)) return "audio/mpeg";
4587
+ if (/mulaw|mu-law/i.test(outputFormat)) return "audio/basic";
4588
+ if (/alaw|a-law/i.test(outputFormat)) return "audio/alaw";
4589
+ if (/siren/i.test(outputFormat)) return "audio/siren";
4551
4590
  if (/ogg/i.test(outputFormat)) return "audio/ogg";
4552
4591
  if (/webm/i.test(outputFormat)) return "audio/webm";
4553
4592
  if (/raw/i.test(outputFormat)) return "audio/L16";
@@ -4576,6 +4615,27 @@ function createSpeechConfig(config) {
4576
4615
  }
4577
4616
 
4578
4617
  // packages/azure-tts-client/src/synthesis.ts
4618
+ function computeChunkFingerprint(ssml, outputFormat = DEFAULT_OUTPUT_FORMAT) {
4619
+ const readAttribute3 = (name) => {
4620
+ const pattern = new RegExp(`(?:${name})\\s*=\\s*[\\"']([^\\"']*)`, "gi");
4621
+ return [...ssml.matchAll(pattern)].map((match) => match[1] ?? "").join("|");
4622
+ };
4623
+ const payload = JSON.stringify({
4624
+ ssml,
4625
+ outputFormat,
4626
+ voice: readAttribute3("(?:name|voice)"),
4627
+ language: readAttribute3("(?:xml:lang|lang)"),
4628
+ rate: readAttribute3("rate"),
4629
+ pitch: readAttribute3("pitch")
4630
+ });
4631
+ let hash = 0xcbf29ce484222325n;
4632
+ const mask = 0xffffffffffffffffn;
4633
+ for (let index = 0; index < payload.length; index += 1) {
4634
+ hash ^= BigInt(payload.charCodeAt(index));
4635
+ hash = hash * 0x100000001b3n & mask;
4636
+ }
4637
+ return `fnv1a64-${hash.toString(16).padStart(16, "0")}`;
4638
+ }
4579
4639
  function ascii(bytes, offset, value) {
4580
4640
  return [...value].every((character, index) => bytes[offset + index] === character.charCodeAt(0));
4581
4641
  }
@@ -4615,9 +4675,11 @@ function parseWav(buffer) {
4615
4675
  }
4616
4676
  return { chunks, data, format };
4617
4677
  }
4618
- function formatNumber(format, pattern, fallback) {
4619
- const match = pattern.exec(format);
4620
- return match?.[1] ? Number(match[1]) : fallback;
4678
+ function formatSampleRate(format) {
4679
+ const match = /(?:^|-)(\d+)(khz|hz)(?:-|$)/i.exec(format);
4680
+ if (!match?.[1] || !match[2]) return 0;
4681
+ const value = Number(match[1]);
4682
+ return match[2].toLowerCase() === "khz" ? value * 1e3 : value;
4621
4683
  }
4622
4684
  function formatChannels(format, fallback) {
4623
4685
  if (/stereo|2ch|dual/i.test(format)) return 2;
@@ -4625,11 +4687,13 @@ function formatChannels(format, fallback) {
4625
4687
  return fallback;
4626
4688
  }
4627
4689
  function formatAudioSpecification(format) {
4628
- const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
4690
+ const sampleRate = formatSampleRate(format);
4629
4691
  const channels = formatChannels(format, 0);
4630
4692
  const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
4631
4693
  const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1e3 : void 0;
4632
- 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";
4694
+ const codec = /mp3|mpeg/i.test(format) ? "mp3" : /opus/i.test(format) ? "opus" : /silk/i.test(format) ? "silk" : /mulaw|mu-law/i.test(format) ? "mulaw" : /alaw|a-law/i.test(format) ? "alaw" : /siren/i.test(format) ? "siren" : /pcm/i.test(format) ? "pcm" : "unknown";
4695
+ const bitDepthMatch = /(\d+)bit/i.exec(format);
4696
+ 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;
4633
4697
  return {
4634
4698
  format,
4635
4699
  mimeType: resolveMimeType(format),
@@ -4637,7 +4701,10 @@ function formatAudioSpecification(format) {
4637
4701
  sampleRate,
4638
4702
  channels,
4639
4703
  ...bitrate ? { bitrate } : {},
4640
- isCompressed: codec === "mp3" || codec === "opus" || codec === "silk"
4704
+ ...bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {},
4705
+ ...container ? { container } : {},
4706
+ isVbr: /vbr/i.test(format),
4707
+ isCompressed: codec !== "pcm" && codec !== "unknown"
4641
4708
  };
4642
4709
  }
4643
4710
  function parseMp3Specification(buffer, format) {
@@ -4672,6 +4739,8 @@ function parseMp3Specification(buffer, format) {
4672
4739
  sampleRate,
4673
4740
  channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
4674
4741
  bitrate: bitrateKbps * 1e3,
4742
+ container: "mp3-raw",
4743
+ isVbr: false,
4675
4744
  isCompressed: true
4676
4745
  };
4677
4746
  }
@@ -4686,24 +4755,43 @@ function inspectAudioSpecification(buffer, format) {
4686
4755
  const channels = view.getUint16(2, true);
4687
4756
  const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
4688
4757
  const formatCode = view.getUint16(0, true);
4758
+ const namedCodec = formatAudioSpecification(format).codec;
4759
+ const codec = formatCode === 1 ? "pcm" : formatCode === 6 ? "alaw" : formatCode === 7 ? "mulaw" : namedCodec === "siren" ? "siren" : "unknown";
4689
4760
  return {
4690
4761
  format,
4691
4762
  mimeType: "audio/wav",
4692
- codec: formatCode === 1 ? "pcm" : "unknown",
4763
+ codec,
4693
4764
  sampleRate,
4694
4765
  channels,
4695
4766
  ...sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {},
4696
- isCompressed: formatCode !== 1
4767
+ bitDepth: bitsPerSample,
4768
+ container: "riff-wave",
4769
+ isVbr: false,
4770
+ isCompressed: codec !== "pcm" && codec !== "unknown"
4697
4771
  };
4698
4772
  }
4699
4773
  if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
4700
- return formatAudioSpecification(format);
4774
+ const specification = formatAudioSpecification(format);
4775
+ if (specification.container === "raw") validateRawAudioBuffer(buffer, specification);
4776
+ return specification;
4777
+ }
4778
+ function validateRawAudioBuffer(buffer, specification) {
4779
+ if (specification.sampleRate <= 0 || specification.channels <= 0 || specification.bitDepth === void 0) {
4780
+ throw new Error(`RAW audio format "${specification.format}" does not define a complete audio specification.`);
4781
+ }
4782
+ if (specification.codec === "siren" || specification.codec === "silk") return;
4783
+ const bytesPerFrame = specification.channels * Math.ceil(specification.bitDepth / 8);
4784
+ if (bytesPerFrame <= 0 || buffer.byteLength % bytesPerFrame !== 0) {
4785
+ throw new Error(
4786
+ `RAW audio buffer size ${buffer.byteLength} is not aligned to ${bytesPerFrame}-byte audio frames for "${specification.format}".`
4787
+ );
4788
+ }
4701
4789
  }
4702
4790
  function validateAudioSpecifications(specs) {
4703
4791
  const first = specs[0];
4704
4792
  if (!first) return;
4705
4793
  const mismatch = specs.find(
4706
- (spec) => spec.sampleRate !== first.sampleRate || spec.channels !== first.channels || first.bitrate !== void 0 && spec.bitrate !== void 0 && spec.bitrate !== first.bitrate
4794
+ (spec) => spec.sampleRate !== first.sampleRate || spec.channels !== first.channels || spec.codec !== first.codec || 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
4707
4795
  );
4708
4796
  if (mismatch)
4709
4797
  throw new AudioFormatMismatchError(
@@ -4786,6 +4874,30 @@ function isWavFormat(format) {
4786
4874
  function isRawFormat(format) {
4787
4875
  return /^raw(?:-|$)/i.test(format);
4788
4876
  }
4877
+ function validateMergedAudioBuffer(merged, format, buffers, inputSpecs, outputMimeType) {
4878
+ if (!(merged instanceof ArrayBuffer) || merged.byteLength === 0) {
4879
+ throw new MergeError("The custom audio merger returned an empty or invalid audio buffer.");
4880
+ }
4881
+ const specification = inspectAudioSpecification(merged, format);
4882
+ if (!outputMimeType.trim()) throw new MergeError("The custom audio merger output MIME type cannot be empty.");
4883
+ const firstInput = inputSpecs[0];
4884
+ if (firstInput && (specification.sampleRate !== firstInput.sampleRate || specification.channels !== firstInput.channels || specification.codec !== firstInput.codec || firstInput.bitDepth !== void 0 && specification.bitDepth !== firstInput.bitDepth)) {
4885
+ throw new AudioFormatMismatchError("The custom audio merger returned an incompatible audio stream.", [
4886
+ ...inputSpecs,
4887
+ specification
4888
+ ]);
4889
+ }
4890
+ if (isRawFormat(format)) {
4891
+ const expectedSize = buffers.reduce((total, input) => total + input.byteLength, 0);
4892
+ if (merged.byteLength !== expectedSize) {
4893
+ throw new MergeError(
4894
+ `The custom audio merger returned ${merged.byteLength} bytes; ${expectedSize} were expected.`
4895
+ );
4896
+ }
4897
+ validateRawAudioBuffer(merged, specification);
4898
+ }
4899
+ return specification;
4900
+ }
4789
4901
  function resolveMergeAudioFormat(format) {
4790
4902
  if (isWavFormat(format)) return "wav";
4791
4903
  if (isMp3Format(format)) return "mp3";
@@ -4838,7 +4950,7 @@ function closeSpeechResources(speechConfig, synthesizer) {
4838
4950
  }
4839
4951
  }
4840
4952
  var ticksToMilliseconds = (ticks) => Math.max(0, ticks) / 1e4;
4841
- async function synthesizeSsml(ssml, config) {
4953
+ async function synthesizeSsmlOnce(ssml, config) {
4842
4954
  if (config.signal?.aborted) {
4843
4955
  throw new SynthesisCancelledError();
4844
4956
  }
@@ -4906,9 +5018,7 @@ async function synthesizeSsml(ssml, config) {
4906
5018
  };
4907
5019
  }
4908
5020
  if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) {
4909
- const unmapped = { mappingStatus: "unmapped" };
4910
- Object.defineProperty(unmapped, "mappingStatus", { value: "unmapped", enumerable: false });
4911
- return unmapped;
5021
+ return { mappingStatus: "unmapped" };
4912
5022
  }
4913
5023
  const value = text ?? "";
4914
5024
  let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? offsetHint : -1;
@@ -4970,6 +5080,13 @@ async function synthesizeSsml(ssml, config) {
4970
5080
  rejectWithError(err);
4971
5081
  return;
4972
5082
  }
5083
+ let audioSpec;
5084
+ try {
5085
+ audioSpec = inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT);
5086
+ } catch (error) {
5087
+ rejectWithError(error);
5088
+ return;
5089
+ }
4973
5090
  settled = true;
4974
5091
  cleanup();
4975
5092
  closeResources();
@@ -4990,8 +5107,13 @@ async function synthesizeSsml(ssml, config) {
4990
5107
  ...config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
4991
5108
  ...requestId ? { requestId } : {}
4992
5109
  };
4993
- if (event.mappingStatus === "unmapped")
5110
+ if (event.mappingStatus === "unmapped") {
4994
5111
  Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
5112
+ Object.defineProperty(mapped, "toJSON", {
5113
+ value: () => ({ ...mapped, mappingStatus: "unmapped" }),
5114
+ enumerable: false
5115
+ });
5116
+ }
4995
5117
  return mapped;
4996
5118
  };
4997
5119
  const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
@@ -5000,8 +5122,8 @@ async function synthesizeSsml(ssml, config) {
5000
5122
  resolve({
5001
5123
  audioData: result.audioData,
5002
5124
  durationMs,
5003
- audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
5004
- mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
5125
+ audioSpec,
5126
+ mimeType: config.outputMimeType ?? resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
5005
5127
  ...config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {},
5006
5128
  ...requestId ? { requestId } : {},
5007
5129
  ...sourceBoundaries.length > 0 ? { boundaries: sourceBoundaries, wordBoundary: sourceBoundaries, wordBoundaries: sourceBoundaries } : {},
@@ -5014,10 +5136,11 @@ async function synthesizeSsml(ssml, config) {
5014
5136
  abortHandler = () => rejectWithError(new SynthesisCancelledError());
5015
5137
  config.signal.addEventListener("abort", abortHandler, { once: true });
5016
5138
  }
5017
- if (config.timeoutMs !== void 0 && config.timeoutMs > 0) {
5139
+ const timeoutMs = config.timeouts?.perChunkMs ?? config.timeouts?.totalJobMs ?? config.timeoutMs;
5140
+ if (timeoutMs !== void 0 && timeoutMs > 0) {
5018
5141
  timeout = setTimeout(
5019
- () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeoutMs} ms.`)),
5020
- config.timeoutMs
5142
+ () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
5143
+ timeoutMs
5021
5144
  );
5022
5145
  }
5023
5146
  synthesizer.speakSsmlAsync(ssml, cb, rejectWithError);
@@ -5036,7 +5159,9 @@ function isRetryableSynthesisError(error) {
5036
5159
  if (typeof status === "number") return status === 429 || status >= 500 && status < 600;
5037
5160
  return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
5038
5161
  }
5039
- function retryDelay(options, retryAttempt) {
5162
+ function retryDelay(options, retryAttempt, error) {
5163
+ const retryAfterMs = getRetryAfterDelayMs(error);
5164
+ if (retryAfterMs !== void 0) return retryAfterMs;
5040
5165
  const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
5041
5166
  return Math.floor(Math.random() * (base + 1));
5042
5167
  }
@@ -5064,32 +5189,102 @@ async function waitForRetry(delayMs, signal) {
5064
5189
  }
5065
5190
  });
5066
5191
  }
5067
- async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
5192
+ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry, deadlineAtMs) {
5068
5193
  const options = retryOptions ? {
5069
5194
  maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
5070
5195
  initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
5071
- maxDelayMs: Math.max(0, retryOptions.maxDelayMs)
5196
+ maxDelayMs: Math.max(0, retryOptions.maxDelayMs),
5197
+ shouldRetry: retryOptions.shouldRetry
5072
5198
  } : void 0;
5073
5199
  let attempt = 0;
5074
5200
  while (true) {
5075
5201
  if (config.signal?.aborted) throw new SynthesisCancelledError();
5076
5202
  try {
5077
- return await synthesizeSsml(ssml, config);
5203
+ return await synthesizeSsmlOnce(ssml, config);
5078
5204
  } catch (error) {
5079
- if (!options || attempt >= options.maxRetries || !isRetryableSynthesisError(error)) throw error;
5205
+ if (!options || attempt >= options.maxRetries || !(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error)))
5206
+ throw error;
5080
5207
  attempt += 1;
5081
- const delayMs = retryDelay(options, attempt);
5208
+ const delayMs = retryDelay(options, attempt, error);
5209
+ const remainingMs = deadlineAtMs === void 0 ? void 0 : Math.max(0, deadlineAtMs - Date.now());
5210
+ if (getRetryAfterDelayMs(error) !== void 0 && (delayMs > options.maxDelayMs || remainingMs !== void 0 && delayMs > remainingMs)) {
5211
+ throw new SynthesisTimeoutError(
5212
+ remainingMs === void 0 ? `Retry-After exceeded maxDelayMs (${options.maxDelayMs} ms).` : `Retry-After exceeded the remaining total job timeout (${remainingMs} ms).`
5213
+ );
5214
+ }
5082
5215
  onRetry(attempt, delayMs);
5083
- await waitForRetry(delayMs, config.signal);
5216
+ await waitForRetry(Math.min(delayMs, remainingMs ?? delayMs), config.signal);
5084
5217
  }
5085
5218
  }
5086
5219
  }
5220
+ async function synthesizeSsml(ssml, config) {
5221
+ const totalJobMs = config.timeouts?.totalJobMs;
5222
+ const deadlineAtMs = totalJobMs !== void 0 && totalJobMs > 0 ? Date.now() + totalJobMs : void 0;
5223
+ if (!config.retryOptions) return synthesizeSsmlOnce(ssml, config);
5224
+ return synthesizeWithRetry(ssml, config, config.retryOptions, () => void 0, deadlineAtMs);
5225
+ }
5226
+ function createAbortScope(parent, timeoutMs) {
5227
+ const controller = new AbortController();
5228
+ let didTimeout = false;
5229
+ const onAbort = () => controller.abort();
5230
+ if (parent?.aborted) controller.abort();
5231
+ parent?.addEventListener("abort", onAbort, { once: true });
5232
+ const timer = timeoutMs !== void 0 && timeoutMs > 0 ? setTimeout(() => {
5233
+ didTimeout = true;
5234
+ controller.abort();
5235
+ }, timeoutMs) : void 0;
5236
+ return {
5237
+ signal: controller.signal,
5238
+ timedOut: () => didTimeout,
5239
+ dispose: () => {
5240
+ if (timer) clearTimeout(timer);
5241
+ parent?.removeEventListener("abort", onAbort);
5242
+ },
5243
+ abort: () => controller.abort()
5244
+ };
5245
+ }
5246
+ async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs, onRetry, deadlineAtMs) {
5247
+ const scope = createAbortScope(config.signal, timeoutMs);
5248
+ try {
5249
+ return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry, deadlineAtMs);
5250
+ } catch (error) {
5251
+ if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
5252
+ throw error;
5253
+ } finally {
5254
+ scope.dispose();
5255
+ }
5256
+ }
5087
5257
  async function synthesizeSsmlChunks(chunks, config) {
5088
- const results = new Array(chunks.length);
5089
5258
  const totalChunks = chunks.length;
5259
+ const inputs = chunks.map((chunk) => typeof chunk === "string" ? { ssml: chunk } : chunk);
5260
+ const fingerprints = inputs.map(
5261
+ (chunk) => computeChunkFingerprint(chunk.ssml, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT)
5262
+ );
5263
+ const results = new Array(totalChunks);
5264
+ const cachedChunks = new Map((config.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
5265
+ const invalidCachedIndices = /* @__PURE__ */ new Set();
5266
+ const chunkStates = inputs.map((_chunk, chunkIndex) => ({
5267
+ chunkIndex,
5268
+ status: "pending",
5269
+ canResume: true
5270
+ }));
5271
+ for (const [index, cached] of cachedChunks) {
5272
+ if (index < 0 || index >= totalChunks) continue;
5273
+ const isValid = config.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index];
5274
+ if (isValid) {
5275
+ results[index] = { ...cached };
5276
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
5277
+ } else {
5278
+ invalidCachedIndices.add(index);
5279
+ }
5280
+ }
5281
+ const requestedIndices = config.resumeChunkIndices ? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks)) : void 0;
5282
+ const shouldSynthesize = (index) => (!cachedChunks.has(index) || invalidCachedIndices.has(index)) && (requestedIndices === void 0 || requestedIndices.has(index) || invalidCachedIndices.has(index));
5283
+ const jobStartedAt = Date.now();
5284
+ const jobDeadlineAt = config.timeouts?.totalJobMs !== void 0 && config.timeouts.totalJobMs > 0 ? jobStartedAt + config.timeouts.totalJobMs : void 0;
5285
+ const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
5090
5286
  const report = (event) => config.onProgress?.(event);
5091
- for (const [index, chunk] of chunks.entries()) {
5092
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
5287
+ for (const [index, input] of inputs.entries()) {
5093
5288
  report({
5094
5289
  currentChunk: index,
5095
5290
  totalChunks,
@@ -5100,15 +5295,18 @@ async function synthesizeSsmlChunks(chunks, config) {
5100
5295
  durationMs: 0
5101
5296
  });
5102
5297
  }
5103
- let completed = 0;
5298
+ let completed = [...results].filter((result) => result !== void 0).length;
5104
5299
  let nextIndex = 0;
5105
5300
  const concurrency = resolveConcurrency(config.concurrency, chunks.length);
5301
+ let firstError;
5302
+ const failedIndices = /* @__PURE__ */ new Set();
5106
5303
  const worker = async () => {
5107
5304
  while (true) {
5108
5305
  const index = nextIndex++;
5109
5306
  if (index >= chunks.length) return;
5110
- const chunk = chunks[index];
5111
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
5307
+ if (!shouldSynthesize(index)) continue;
5308
+ if (firstError && config.cancelOnFailure !== false) return;
5309
+ const input = inputs[index];
5112
5310
  report({
5113
5311
  currentChunk: completed,
5114
5312
  totalChunks,
@@ -5120,10 +5318,11 @@ async function synthesizeSsmlChunks(chunks, config) {
5120
5318
  });
5121
5319
  const startedAt = Date.now();
5122
5320
  try {
5123
- const result = await synthesizeWithRetry(
5321
+ const result = await synthesizeChunkWithTimeout(
5124
5322
  input.ssml,
5125
5323
  {
5126
5324
  ...config,
5325
+ signal: scope.signal,
5127
5326
  ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
5128
5327
  ...input.sourceNodePath ?? config.sourceNodePath ? { sourceNodePath: [...input.sourceNodePath ?? config.sourceNodePath ?? []] } : {},
5129
5328
  ...input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {},
@@ -5132,6 +5331,7 @@ async function synthesizeSsmlChunks(chunks, config) {
5132
5331
  onProgress: void 0
5133
5332
  },
5134
5333
  config.retryOptions,
5334
+ config.timeouts?.chunkWithRetriesMs ?? config.timeouts?.perChunkMs ?? config.timeoutMs,
5135
5335
  (retryAttempt, nextRetryDelayMs) => report({
5136
5336
  currentChunk: completed,
5137
5337
  totalChunks,
@@ -5143,9 +5343,11 @@ async function synthesizeSsmlChunks(chunks, config) {
5143
5343
  retryAttempt,
5144
5344
  nextRetryDelayMs,
5145
5345
  isRetrying: true
5146
- })
5346
+ }),
5347
+ jobDeadlineAt
5147
5348
  );
5148
5349
  results[index] = result;
5350
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result };
5149
5351
  completed += 1;
5150
5352
  report({
5151
5353
  currentChunk: completed,
@@ -5157,6 +5359,16 @@ async function synthesizeSsmlChunks(chunks, config) {
5157
5359
  durationMs: Date.now() - startedAt
5158
5360
  });
5159
5361
  } catch (error) {
5362
+ const wasCancelled = firstError !== void 0 || scope.signal.aborted && !scope.timedOut();
5363
+ firstError ?? (firstError = scope.timedOut() ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`) : error);
5364
+ if (!wasCancelled) failedIndices.add(index);
5365
+ chunkStates[index] = {
5366
+ chunkIndex: index,
5367
+ status: wasCancelled ? "cancelled" : "failed",
5368
+ isOriginalFailure: !wasCancelled,
5369
+ canResume: true,
5370
+ error
5371
+ };
5160
5372
  report({
5161
5373
  currentChunk: completed,
5162
5374
  totalChunks,
@@ -5167,16 +5379,49 @@ async function synthesizeSsmlChunks(chunks, config) {
5167
5379
  durationMs: Date.now() - startedAt,
5168
5380
  error
5169
5381
  });
5170
- throw error;
5382
+ if (config.cancelOnFailure !== false) scope.abort();
5383
+ return;
5171
5384
  }
5172
5385
  }
5173
5386
  };
5174
- await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
5175
- const orderedResults = results.filter((result) => result !== void 0);
5176
- return mergeSynthesisResults(orderedResults, {
5177
- format: config.outputFormat ?? DEFAULT_OUTPUT_FORMAT,
5178
- signal: config.signal
5179
- });
5387
+ try {
5388
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
5389
+ if (firstError) throw firstError;
5390
+ const orderedResults = results.filter((result) => result !== void 0);
5391
+ return await mergeSynthesisResults(orderedResults, {
5392
+ format: config.outputFormat ?? DEFAULT_OUTPUT_FORMAT,
5393
+ signal: scope.signal,
5394
+ customMerger: config.customMerger,
5395
+ outputMimeType: config.outputMimeType,
5396
+ postMergeValidator: config.postMergeValidator
5397
+ });
5398
+ } catch (error) {
5399
+ if (firstError && config.cancelOnFailure !== false) {
5400
+ for (const [chunkIndex, state] of chunkStates.entries()) {
5401
+ if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
5402
+ chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
5403
+ }
5404
+ }
5405
+ }
5406
+ const synthesizedChunks = results.flatMap(
5407
+ (result, chunkIndex) => result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : []
5408
+ );
5409
+ const partial = {
5410
+ synthesizedChunks,
5411
+ completedChunks: synthesizedChunks,
5412
+ pendingChunkIndices: chunkStates.flatMap(
5413
+ (state) => state.status === "pending" || state.status === "cancelled" || state.status === "failed" ? [state.chunkIndex] : []
5414
+ ),
5415
+ failedChunkIndices: [...failedIndices],
5416
+ cancelledChunkIndices: chunkStates.filter((state) => state.status === "cancelled").map((state) => state.chunkIndex),
5417
+ chunkStates,
5418
+ totalChunks
5419
+ };
5420
+ if (error && typeof error === "object") error.partialResult = partial;
5421
+ throw error;
5422
+ } finally {
5423
+ scope.dispose();
5424
+ }
5180
5425
  }
5181
5426
  function createMergedResult(results, audioData, format, audioSpec, outputMimeType) {
5182
5427
  const boundaries = [];
@@ -5267,16 +5512,26 @@ function mergeSynthesisResults(results, options) {
5267
5512
  })
5268
5513
  ).then((merged) => {
5269
5514
  if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
5270
- if (!(merged instanceof ArrayBuffer) || buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
5271
- throw new MergeError("The custom audio merger returned an invalid audio buffer.");
5272
5515
  if (signal.aborted) throw new SynthesisCancelledError();
5273
- return createMergedResult(
5274
- results,
5516
+ const mergedSpec = validateMergedAudioBuffer(
5275
5517
  merged,
5276
5518
  format,
5277
- inspectAudioSpecification(merged, format),
5278
- resolvedOptions.outputMimeType
5519
+ buffers,
5520
+ inputSpecs,
5521
+ resolvedOptions.outputMimeType ?? resolveMimeType(format)
5279
5522
  );
5523
+ const result = createMergedResult(results, merged, format, mergedSpec, resolvedOptions.outputMimeType);
5524
+ return Promise.resolve(
5525
+ resolvedOptions.postMergeValidator?.(result, {
5526
+ format,
5527
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
5528
+ inputSpecs,
5529
+ signal
5530
+ })
5531
+ ).then((valid) => {
5532
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
5533
+ return result;
5534
+ });
5280
5535
  }).catch((error) => {
5281
5536
  if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
5282
5537
  throw error;
@@ -5284,13 +5539,28 @@ function mergeSynthesisResults(results, options) {
5284
5539
  });
5285
5540
  }
5286
5541
  try {
5287
- return createMergedResult(
5542
+ const result = createMergedResult(
5288
5543
  results,
5289
5544
  mergeAudioBuffers(buffers, { format }),
5290
5545
  format,
5291
5546
  inputSpecs[0],
5292
5547
  resolvedOptions.outputMimeType
5293
5548
  );
5549
+ if (resolvedOptions.postMergeValidator) {
5550
+ const validation = resolvedOptions.postMergeValidator(result, {
5551
+ format,
5552
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
5553
+ inputSpecs,
5554
+ signal
5555
+ });
5556
+ if (validation instanceof Promise)
5557
+ return validation.then((valid) => {
5558
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
5559
+ return result;
5560
+ });
5561
+ if (validation === false) throw new MergeError("The post-merge validator rejected the merged audio.");
5562
+ }
5563
+ return result;
5294
5564
  } catch (error) {
5295
5565
  if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
5296
5566
  throw error;
@@ -5311,8 +5581,52 @@ var ChunkValidationError = class extends Error {
5311
5581
  this.diagnostics = diagnostics;
5312
5582
  }
5313
5583
  };
5314
- function failure(error) {
5315
- return { ok: false, success: false, status: error.kind, error };
5584
+ var BatchChunkValidationError = class extends ChunkValidationError {
5585
+ constructor(chunkDiagnostics) {
5586
+ const first = chunkDiagnostics[0];
5587
+ super(first?.chunkIndex ?? -1, first?.diagnostics ?? []);
5588
+ this.name = "BatchChunkValidationError";
5589
+ this.message = `SSML validation failed for ${chunkDiagnostics.length} chunk(s); the Azure Speech API was not called.`;
5590
+ this.chunkDiagnostics = chunkDiagnostics;
5591
+ this.totalErrorCount = chunkDiagnostics.reduce((total, chunk) => total + chunk.diagnostics.length, 0);
5592
+ this.errorCount = this.totalErrorCount;
5593
+ this.totalErrors = this.totalErrorCount;
5594
+ }
5595
+ };
5596
+ function failure(error, partialResult) {
5597
+ return {
5598
+ ok: false,
5599
+ success: false,
5600
+ status: error.kind,
5601
+ error,
5602
+ ...partialResult ? { partialResult } : {}
5603
+ };
5604
+ }
5605
+ function partialResultFrom(error) {
5606
+ if (!error || typeof error !== "object") return void 0;
5607
+ const partial = error.partialResult;
5608
+ if (!partial || typeof partial !== "object") return void 0;
5609
+ return partial;
5610
+ }
5611
+ function createSafeAbortScope(parent, timeoutMs) {
5612
+ const controller = new AbortController();
5613
+ let didTimeout = false;
5614
+ const onAbort = () => controller.abort();
5615
+ if (parent?.aborted) controller.abort();
5616
+ parent?.addEventListener("abort", onAbort, { once: true });
5617
+ const timer = timeoutMs !== void 0 && timeoutMs > 0 ? setTimeout(() => {
5618
+ didTimeout = true;
5619
+ controller.abort();
5620
+ }, timeoutMs) : void 0;
5621
+ return {
5622
+ signal: controller.signal,
5623
+ timedOut: () => didTimeout,
5624
+ dispose: () => {
5625
+ if (timer) clearTimeout(timer);
5626
+ parent?.removeEventListener("abort", onAbort);
5627
+ },
5628
+ abort: () => controller.abort()
5629
+ };
5316
5630
  }
5317
5631
  function isRetryable(error) {
5318
5632
  if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
@@ -5327,16 +5641,20 @@ function delayForRetry(options, attempt) {
5327
5641
  const base = Math.min(maxDelay, Math.max(0, options.initialDelayMs) * 2 ** Math.max(0, attempt - 1));
5328
5642
  return Math.floor(Math.random() * (base + 1));
5329
5643
  }
5644
+ function retryDelayForError(options, attempt, error) {
5645
+ return getRetryAfterDelayMs(error) ?? delayForRetry(options, attempt);
5646
+ }
5330
5647
  function resolveConcurrency2(value, total) {
5331
5648
  if (value === void 0) return 1;
5332
5649
  if (value === Infinity) return Math.max(1, total);
5333
5650
  return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
5334
5651
  }
5335
- async function retryableSynthesis(synthesize, options, signal, onRetry) {
5652
+ async function retryableSynthesis(synthesize, options, signal, onRetry, deadlineAtMs) {
5336
5653
  const retry = options ? {
5337
5654
  maxRetries: Math.max(0, Math.floor(options.maxRetries)),
5338
5655
  initialDelayMs: options.initialDelayMs,
5339
- maxDelayMs: options.maxDelayMs
5656
+ maxDelayMs: options.maxDelayMs,
5657
+ shouldRetry: options.shouldRetry
5340
5658
  } : void 0;
5341
5659
  let attempt = 0;
5342
5660
  while (true) {
@@ -5344,9 +5662,15 @@ async function retryableSynthesis(synthesize, options, signal, onRetry) {
5344
5662
  try {
5345
5663
  return await synthesize();
5346
5664
  } catch (error) {
5347
- if (!retry || attempt >= retry.maxRetries || !isRetryable(error)) throw error;
5665
+ if (!retry || attempt >= retry.maxRetries || !(retry.shouldRetry?.(error, attempt + 1) ?? isRetryable(error)))
5666
+ throw error;
5348
5667
  attempt += 1;
5349
- const delayMs = delayForRetry(retry, attempt);
5668
+ const delayMs = retryDelayForError(retry, attempt, error);
5669
+ const retryAfterMs = getRetryAfterDelayMs(error);
5670
+ const remainingMs = deadlineAtMs === void 0 ? void 0 : Math.max(0, deadlineAtMs - Date.now());
5671
+ if (retryAfterMs !== void 0 && (retryAfterMs > retry.maxDelayMs || remainingMs !== void 0 && retryAfterMs > remainingMs)) {
5672
+ throw new Error("Speech synthesis timed out because Retry-After exceeded the available retry budget.");
5673
+ }
5350
5674
  onRetry(attempt, delayMs);
5351
5675
  if (delayMs > 0)
5352
5676
  await new Promise((resolve, reject) => {
@@ -5370,7 +5694,7 @@ function sharedValidationOptions(options, signal) {
5370
5694
  const runner = createAzureUrlValidatorRunner2(validator, {
5371
5695
  ...options.urlValidation ?? {},
5372
5696
  ...options.urlValidatorConcurrency !== void 0 ? { concurrency: options.urlValidatorConcurrency } : {},
5373
- ...options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
5697
+ ...options.timeouts?.urlValidationMs !== void 0 ? { timeoutMs: options.timeouts.urlValidationMs } : options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
5374
5698
  ...signal ? { signal } : {},
5375
5699
  ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
5376
5700
  });
@@ -5394,20 +5718,31 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
5394
5718
  diagnostics: errors
5395
5719
  });
5396
5720
  }
5721
+ const jobScope = options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts.totalJobMs) : void 0;
5397
5722
  try {
5398
5723
  return {
5399
5724
  ok: true,
5400
5725
  success: true,
5401
5726
  status: "success",
5402
- value: await client.synthesizeSsml(ssml, { signal: options.signal })
5727
+ value: await client.synthesizeSsml(ssml, {
5728
+ signal: jobScope?.signal ?? options.signal,
5729
+ timeoutMs: options.timeouts?.perChunkMs,
5730
+ timeouts: options.timeouts
5731
+ })
5403
5732
  };
5404
5733
  } catch (error) {
5734
+ if (jobScope?.timedOut()) return failure(toSynthesisError(new Error("Speech synthesis timed out.")));
5405
5735
  const synthesisError = toSynthesisError(error);
5406
5736
  return failure(synthesisError);
5737
+ } finally {
5738
+ jobScope?.dispose();
5407
5739
  }
5408
5740
  }
5409
5741
  async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
5410
- const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
5742
+ const validationOptions = sharedValidationOptions(
5743
+ { ...options.validation ?? options, timeouts: options.timeouts },
5744
+ options.signal
5745
+ );
5411
5746
  if (options.signal?.aborted) {
5412
5747
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
5413
5748
  return failure(error);
@@ -5441,16 +5776,17 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
5441
5776
  return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
5442
5777
  })
5443
5778
  );
5444
- const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
5445
5779
  if (options.signal?.aborted) {
5446
5780
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
5447
5781
  return failure(error);
5448
5782
  }
5449
- if (firstInvalidIndex >= 0) {
5450
- const error = new ChunkValidationError(firstInvalidIndex, validations[firstInvalidIndex] ?? []);
5451
- pending(firstInvalidIndex, "failed", error);
5783
+ const chunkDiagnostics = validations.map((diagnostics, chunkIndex) => ({ chunkIndex, diagnostics })).filter((entry) => entry.diagnostics.length > 0);
5784
+ if (chunkDiagnostics.length > 0) {
5785
+ const error = new BatchChunkValidationError(chunkDiagnostics);
5786
+ for (const entry of chunkDiagnostics) pending(entry.chunkIndex, "failed", error);
5452
5787
  return failure(error);
5453
5788
  }
5789
+ let fallbackJobScope;
5454
5790
  try {
5455
5791
  if (client.synthesizeChunks) {
5456
5792
  const normalizedChunks = chunks.map((chunk) => {
@@ -5462,20 +5798,57 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
5462
5798
  outputFormat: options.outputFormat,
5463
5799
  signal: options.signal,
5464
5800
  timeoutMs: options.timeoutMs,
5801
+ timeouts: options.timeouts,
5465
5802
  sourceNodePath: options.sourceNodePath,
5466
5803
  concurrency: options.concurrency,
5467
- retryOptions: options.retryOptions
5804
+ retryOptions: options.retryOptions,
5805
+ cancelOnFailure: options.cancelOnFailure,
5806
+ resumeChunks: options.resumeChunks,
5807
+ resumeChunkIndices: options.resumeChunkIndices,
5808
+ customMerger: options.customMerger,
5809
+ outputMimeType: options.outputMimeType,
5810
+ postMergeValidator: options.postMergeValidator,
5811
+ resumeValidation: options.resumeValidation
5468
5812
  });
5469
5813
  return { ok: true, success: true, status: "success", value };
5470
5814
  }
5815
+ const inputs = chunks.map((chunk) => typeof chunk === "string" ? { ssml: chunk } : chunk);
5816
+ const fingerprints = inputs.map((chunk) => computeChunkFingerprint(chunk.ssml, options.outputFormat));
5471
5817
  const results = new Array(chunks.length);
5472
- let completed = 0;
5818
+ const chunkStates = inputs.map((_chunk, chunkIndex) => ({
5819
+ chunkIndex,
5820
+ status: "pending",
5821
+ canResume: true
5822
+ }));
5823
+ const cachedChunks = new Map((options.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
5824
+ const invalidCachedIndices = /* @__PURE__ */ new Set();
5825
+ for (const [index, cached] of cachedChunks) {
5826
+ if (index < 0 || index >= chunks.length) continue;
5827
+ if (options.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index]) {
5828
+ results[index] = cached;
5829
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: cached };
5830
+ } else invalidCachedIndices.add(index);
5831
+ }
5832
+ const requestedIndices = options.resumeChunkIndices ? new Set(options.resumeChunkIndices.filter((index) => index >= 0 && index < chunks.length)) : void 0;
5833
+ const shouldSynthesize = (index) => (!cachedChunks.has(index) || invalidCachedIndices.has(index)) && (requestedIndices === void 0 || requestedIndices.has(index) || invalidCachedIndices.has(index));
5834
+ const jobStartedAt = Date.now();
5835
+ const jobDeadlineAt = options.timeouts?.totalJobMs !== void 0 && options.timeouts.totalJobMs > 0 ? jobStartedAt + options.timeouts.totalJobMs : void 0;
5836
+ const jobScope = chunks.length > 1 || options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs) : void 0;
5837
+ fallbackJobScope = jobScope;
5838
+ const failedIndices = /* @__PURE__ */ new Set();
5839
+ let firstError;
5840
+ let completed = [...results].filter((result) => result !== void 0).length;
5473
5841
  let nextIndex = 0;
5474
5842
  const concurrency = resolveConcurrency2(options.concurrency, chunks.length);
5475
5843
  const worker = async () => {
5476
5844
  while (true) {
5477
5845
  const index = nextIndex++;
5478
5846
  if (index >= chunks.length) return;
5847
+ if (!shouldSynthesize(index)) continue;
5848
+ if (firstError && options.cancelOnFailure !== false) {
5849
+ chunkStates[index] = { chunkIndex: index, status: "cancelled", isOriginalFailure: false, canResume: true };
5850
+ return;
5851
+ }
5479
5852
  const chunk = chunks[index];
5480
5853
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
5481
5854
  const sourceNodePath = input.sourceNodePath;
@@ -5483,28 +5856,41 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
5483
5856
  pending(index, "synthesizing");
5484
5857
  const startedAt = Date.now();
5485
5858
  try {
5486
- const result = await retryableSynthesis(
5487
- () => client.synthesizeSsml(input.ssml, {
5488
- outputFormat: options.outputFormat,
5489
- signal: options.signal,
5490
- timeoutMs: options.timeoutMs,
5491
- sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
5492
- }),
5493
- options.retryOptions,
5494
- options.signal,
5495
- (retryAttempt, nextRetryDelayMs) => options.onProgress?.({
5496
- currentChunk: completed,
5497
- totalChunks: chunks.length,
5498
- percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
5499
- chunkIndex: index,
5500
- originalTextRange: input.originalTextRange,
5501
- status: "synthesizing",
5502
- durationMs: Date.now() - startedAt,
5503
- retryAttempt,
5504
- nextRetryDelayMs,
5505
- isRetrying: true
5506
- })
5507
- );
5859
+ const chunkTimeout = options.timeouts?.chunkWithRetriesMs ?? options.timeouts?.perChunkMs;
5860
+ const chunkScope = chunkTimeout !== void 0 || jobScope ? createSafeAbortScope(jobScope?.signal ?? options.signal, chunkTimeout ?? options.timeoutMs) : void 0;
5861
+ const chunkSignal = chunkScope?.signal ?? options.signal;
5862
+ let result;
5863
+ try {
5864
+ result = await retryableSynthesis(
5865
+ () => client.synthesizeSsml(input.ssml, {
5866
+ outputFormat: options.outputFormat,
5867
+ signal: chunkSignal,
5868
+ timeoutMs: options.timeouts?.perChunkMs ?? options.timeoutMs,
5869
+ sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
5870
+ }),
5871
+ options.retryOptions,
5872
+ chunkSignal,
5873
+ (retryAttempt, nextRetryDelayMs) => options.onProgress?.({
5874
+ currentChunk: completed,
5875
+ totalChunks: chunks.length,
5876
+ percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
5877
+ chunkIndex: index,
5878
+ originalTextRange: input.originalTextRange,
5879
+ status: "synthesizing",
5880
+ durationMs: Date.now() - startedAt,
5881
+ retryAttempt,
5882
+ nextRetryDelayMs,
5883
+ isRetrying: true
5884
+ }),
5885
+ jobDeadlineAt
5886
+ );
5887
+ } catch (error) {
5888
+ if (chunkScope?.timedOut())
5889
+ throw new Error(`Speech synthesis timed out after ${chunkTimeout ?? options.timeoutMs} ms.`);
5890
+ throw error;
5891
+ } finally {
5892
+ chunkScope?.dispose();
5893
+ }
5508
5894
  results[index] = {
5509
5895
  ...result,
5510
5896
  ...input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {},
@@ -5548,6 +5934,7 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
5548
5934
  }))
5549
5935
  } : {}
5550
5936
  };
5937
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
5551
5938
  completed += 1;
5552
5939
  options.onProgress?.({
5553
5940
  currentChunk: completed,
@@ -5559,6 +5946,16 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
5559
5946
  durationMs: Date.now() - startedAt
5560
5947
  });
5561
5948
  } catch (error) {
5949
+ const wasCancelled = firstError !== void 0 || Boolean(jobScope?.signal.aborted && !jobScope?.timedOut());
5950
+ firstError ?? (firstError = error);
5951
+ if (!wasCancelled) failedIndices.add(index);
5952
+ chunkStates[index] = {
5953
+ chunkIndex: index,
5954
+ status: wasCancelled ? "cancelled" : "failed",
5955
+ isOriginalFailure: !wasCancelled,
5956
+ canResume: true,
5957
+ error
5958
+ };
5562
5959
  options.onProgress?.({
5563
5960
  currentChunk: completed,
5564
5961
  totalChunks: chunks.length,
@@ -5569,24 +5966,55 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
5569
5966
  durationMs: Date.now() - startedAt,
5570
5967
  error
5571
5968
  });
5572
- throw error;
5969
+ if (options.cancelOnFailure !== false) jobScope?.abort();
5970
+ return;
5573
5971
  }
5574
5972
  }
5575
5973
  };
5576
5974
  await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
5975
+ if (firstError && options.cancelOnFailure !== false) {
5976
+ for (const [chunkIndex, state] of chunkStates.entries()) {
5977
+ if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
5978
+ chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
5979
+ }
5980
+ }
5981
+ }
5982
+ if (failedIndices.size > 0) {
5983
+ const error = firstError ?? new Error("One or more SSML chunks failed to synthesize.");
5984
+ const synthesizedChunks = results.flatMap(
5985
+ (result, chunkIndex) => result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : []
5986
+ );
5987
+ error.partialResult = {
5988
+ synthesizedChunks,
5989
+ completedChunks: synthesizedChunks,
5990
+ pendingChunkIndices: chunkStates.flatMap(
5991
+ (state) => state.status === "pending" || state.status === "cancelled" || state.status === "failed" ? [state.chunkIndex] : []
5992
+ ),
5993
+ failedChunkIndices: [...failedIndices],
5994
+ cancelledChunkIndices: chunkStates.filter((state) => state.status === "cancelled").map((state) => state.chunkIndex),
5995
+ chunkStates,
5996
+ totalChunks: chunks.length
5997
+ };
5998
+ throw error;
5999
+ }
5577
6000
  const orderedResults = results.filter((result) => result !== void 0);
5578
6001
  return {
5579
6002
  ok: true,
5580
6003
  success: true,
5581
6004
  status: "success",
5582
- value: mergeSynthesisResults(orderedResults, {
6005
+ value: await mergeSynthesisResults(orderedResults, {
5583
6006
  format: options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3",
5584
- signal: options.signal
6007
+ signal: jobScope?.signal ?? options.signal,
6008
+ customMerger: options.customMerger,
6009
+ outputMimeType: options.outputMimeType,
6010
+ postMergeValidator: options.postMergeValidator
5585
6011
  })
5586
6012
  };
5587
6013
  } catch (error) {
5588
6014
  const synthesisError = toSynthesisError(error);
5589
- return failure(synthesisError);
6015
+ return failure(synthesisError, partialResultFrom(error));
6016
+ } finally {
6017
+ fallbackJobScope?.dispose();
5590
6018
  }
5591
6019
  }
5592
6020
  function withValidationSignal(options, signal) {
@@ -5607,14 +6035,23 @@ var AzureTtsClient = class {
5607
6035
  __privateSet(this, _options, options);
5608
6036
  }
5609
6037
  async synthesize(ssml) {
5610
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
6038
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
5611
6039
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
5612
6040
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
5613
- const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs };
6041
+ const config = {
6042
+ endpoint,
6043
+ region,
6044
+ subscriptionKey,
6045
+ outputFormat,
6046
+ signal,
6047
+ timeoutMs,
6048
+ timeouts,
6049
+ retryOptions: __privateGet(this, _options).retryOptions
6050
+ };
5614
6051
  return synthesizeSpeech(ssml, config);
5615
6052
  }
5616
6053
  async synthesizeSsml(ssml, options = {}) {
5617
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
6054
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
5618
6055
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
5619
6056
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
5620
6057
  return synthesizeSsml(ssml, {
@@ -5624,13 +6061,20 @@ var AzureTtsClient = class {
5624
6061
  outputFormat: options.outputFormat ?? outputFormat,
5625
6062
  signal: options.signal ?? signal,
5626
6063
  timeoutMs: options.timeoutMs ?? timeoutMs,
6064
+ timeouts: options.timeouts ?? timeouts,
5627
6065
  sourceNodePath: options.sourceNodePath,
5628
6066
  sourceTextSegments: options.sourceTextSegments,
5629
- sourceMarkers: options.sourceMarkers
6067
+ sourceMarkers: options.sourceMarkers,
6068
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
6069
+ cancelOnFailure: options.cancelOnFailure ?? __privateGet(this, _options).cancelOnFailure,
6070
+ customMerger: options.customMerger ?? __privateGet(this, _options).customMerger,
6071
+ outputMimeType: options.outputMimeType ?? __privateGet(this, _options).outputMimeType,
6072
+ postMergeValidator: options.postMergeValidator ?? __privateGet(this, _options).postMergeValidator,
6073
+ resumeValidation: options.resumeValidation ?? __privateGet(this, _options).resumeValidation
5630
6074
  });
5631
6075
  }
5632
6076
  async synthesizeChunks(chunks, options = {}) {
5633
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
6077
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
5634
6078
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
5635
6079
  return synthesizeSsmlChunks(chunks, {
5636
6080
  endpoint,
@@ -5639,10 +6083,18 @@ var AzureTtsClient = class {
5639
6083
  outputFormat: options.outputFormat ?? outputFormat,
5640
6084
  signal: options.signal ?? signal,
5641
6085
  timeoutMs: options.timeoutMs ?? timeoutMs,
6086
+ timeouts: options.timeouts ?? timeouts,
5642
6087
  sourceNodePath: options.sourceNodePath,
5643
6088
  onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
5644
6089
  concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
5645
- retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
6090
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
6091
+ cancelOnFailure: options.cancelOnFailure ?? __privateGet(this, _options).cancelOnFailure,
6092
+ resumeChunks: options.resumeChunks,
6093
+ resumeChunkIndices: options.resumeChunkIndices,
6094
+ customMerger: options.customMerger ?? __privateGet(this, _options).customMerger,
6095
+ outputMimeType: options.outputMimeType ?? __privateGet(this, _options).outputMimeType,
6096
+ postMergeValidator: options.postMergeValidator ?? __privateGet(this, _options).postMergeValidator,
6097
+ resumeValidation: options.resumeValidation ?? __privateGet(this, _options).resumeValidation
5646
6098
  });
5647
6099
  }
5648
6100
  async synthesizeSsmlSafe(ssml, options = {}) {
@@ -5654,6 +6106,7 @@ var AzureTtsClient = class {
5654
6106
  outputFormat: options.outputFormat ?? __privateGet(this, _options).outputFormat,
5655
6107
  signal: options.signal ?? __privateGet(this, _options).signal,
5656
6108
  timeoutMs: options.timeoutMs ?? __privateGet(this, _options).timeoutMs,
6109
+ timeouts: options.timeouts ?? __privateGet(this, _options).timeouts,
5657
6110
  onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
5658
6111
  concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
5659
6112
  retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
@@ -5747,7 +6200,9 @@ async function fetchAzureVoiceCatalog(options) {
5747
6200
  voiceCount: sortedVoices.length,
5748
6201
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
5749
6202
  apiVersion: AZURE_VOICE_API_VERSION,
5750
- regions
6203
+ regions,
6204
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1e3).toISOString(),
6205
+ regionDiffs: {}
5751
6206
  }
5752
6207
  };
5753
6208
  }
@@ -5757,6 +6212,7 @@ async function fetchAzureVoiceCatalog(options) {
5757
6212
  AzureTtsClient,
5758
6213
  AzureTtsError,
5759
6214
  AzureTtsSdkError,
6215
+ BatchChunkValidationError,
5760
6216
  ChunkValidationError,
5761
6217
  DEFAULT_OUTPUT_FORMAT,
5762
6218
  MergeError,
@@ -5767,6 +6223,7 @@ async function fetchAzureVoiceCatalog(options) {
5767
6223
  buildPartialSsml,
5768
6224
  buildSsml,
5769
6225
  canMergeAudioFormat,
6226
+ computeChunkFingerprint,
5770
6227
  createAzureUrlValidatorRunner,
5771
6228
  extractSsmlText,
5772
6229
  extractSsmlTranslatableText,
@@ -5774,6 +6231,7 @@ async function fetchAzureVoiceCatalog(options) {
5774
6231
  fromPlainTextToSsml,
5775
6232
  getAzureVoiceCatalogMetadata,
5776
6233
  getBuiltInVoiceCatalogMetadata,
6234
+ getRetryAfterDelayMs,
5777
6235
  getSsmlSourceMap,
5778
6236
  inspectAudioSpecification,
5779
6237
  isValidAzureAudioDuration,