@ssml-builder-js/azure-tts-client 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(index_exports, {
41
41
  AzureTtsClient: () => AzureTtsClient,
42
42
  AzureTtsError: () => AzureTtsError,
43
43
  AzureTtsSdkError: () => AzureTtsSdkError,
44
+ BatchChunkValidationError: () => BatchChunkValidationError,
44
45
  ChunkValidationError: () => ChunkValidationError,
45
46
  DEFAULT_OUTPUT_FORMAT: () => DEFAULT_OUTPUT_FORMAT,
46
47
  MergeError: () => MergeError,
@@ -48,7 +49,9 @@ __export(index_exports, {
48
49
  SynthesisTimeoutError: () => SynthesisTimeoutError,
49
50
  UnsupportedMergeFormatError: () => UnsupportedMergeFormatError,
50
51
  canMergeAudioFormat: () => canMergeAudioFormat,
52
+ computeChunkFingerprint: () => computeChunkFingerprint,
51
53
  fetchAzureVoiceCatalog: () => fetchAzureVoiceCatalog,
54
+ getRetryAfterDelayMs: () => getRetryAfterDelayMs,
52
55
  inspectAudioSpecification: () => inspectAudioSpecification,
53
56
  mergeAudioBuffers: () => mergeAudioBuffers,
54
57
  mergeSynthesisResults: () => mergeSynthesisResults,
@@ -64,7 +67,7 @@ module.exports = __toCommonJS(index_exports);
64
67
 
65
68
  // src/errors.ts
66
69
  var AzureTtsError = class extends Error {
67
- constructor(status, statusText, responseBody, requestId) {
70
+ constructor(status, statusText, responseBody, requestId, responseHeaders) {
68
71
  super(`Azure TTS request failed: ${status} ${statusText}`);
69
72
  this.kind = "azure-api-error";
70
73
  this.name = "AzureTtsError";
@@ -72,8 +75,37 @@ var AzureTtsError = class extends Error {
72
75
  this.statusText = statusText;
73
76
  this.responseBody = responseBody;
74
77
  this.requestId = requestId;
78
+ const value = responseHeaders instanceof Headers ? responseHeaders.get("retry-after") : responseHeaders?.["retry-after"] ?? responseHeaders?.["Retry-After"];
79
+ const seconds = value ? Number(value.trim()) : NaN;
80
+ const date = value ? Date.parse(value) : NaN;
81
+ if (Number.isFinite(seconds) && seconds >= 0) this.retryAfterMs = seconds * 1e3;
82
+ else if (Number.isFinite(date)) this.retryAfterMs = Math.max(0, date - Date.now());
75
83
  }
76
84
  };
85
+ function getRetryAfterDelayMs(error) {
86
+ if (error instanceof AzureTtsError && error.retryAfterMs !== void 0) return error.retryAfterMs;
87
+ if (!error || typeof error !== "object") return void 0;
88
+ const candidate = error;
89
+ if (typeof candidate.retryAfterMs === "number" && candidate.retryAfterMs >= 0) return candidate.retryAfterMs;
90
+ const headers = candidate.headers ?? candidate.response?.headers;
91
+ if (headers instanceof Headers) {
92
+ const value = headers.get("retry-after");
93
+ if (!value) return void 0;
94
+ const seconds = Number(value.trim());
95
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
96
+ const date = Date.parse(value);
97
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : void 0;
98
+ }
99
+ if (headers && typeof headers === "object") {
100
+ const value = headers["retry-after"] ?? headers["Retry-After"];
101
+ if (typeof value !== "string") return void 0;
102
+ const seconds = Number(value.trim());
103
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
104
+ const date = Date.parse(value);
105
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : void 0;
106
+ }
107
+ return void 0;
108
+ }
77
109
  var AzureTtsSdkError = class extends AzureTtsError {
78
110
  constructor(errorDetails) {
79
111
  super(0, "Speech SDK", errorDetails, null);
@@ -184,6 +216,9 @@ var OUTPUT_FORMATS = {
184
216
  function resolveMimeType(outputFormat) {
185
217
  if (/(?:wav|wave|riff)/i.test(outputFormat)) return "audio/wav";
186
218
  if (/(?:mp3|mpeg)/i.test(outputFormat)) return "audio/mpeg";
219
+ if (/mulaw|mu-law/i.test(outputFormat)) return "audio/basic";
220
+ if (/alaw|a-law/i.test(outputFormat)) return "audio/alaw";
221
+ if (/siren/i.test(outputFormat)) return "audio/siren";
187
222
  if (/ogg/i.test(outputFormat)) return "audio/ogg";
188
223
  if (/webm/i.test(outputFormat)) return "audio/webm";
189
224
  if (/raw/i.test(outputFormat)) return "audio/L16";
@@ -212,6 +247,27 @@ function createSpeechConfig(config) {
212
247
  }
213
248
 
214
249
  // src/synthesis.ts
250
+ function computeChunkFingerprint(ssml, outputFormat = DEFAULT_OUTPUT_FORMAT) {
251
+ const readAttribute = (name) => {
252
+ const pattern = new RegExp(`(?:${name})\\s*=\\s*[\\"']([^\\"']*)`, "gi");
253
+ return [...ssml.matchAll(pattern)].map((match) => match[1] ?? "").join("|");
254
+ };
255
+ const payload = JSON.stringify({
256
+ ssml,
257
+ outputFormat,
258
+ voice: readAttribute("(?:name|voice)"),
259
+ language: readAttribute("(?:xml:lang|lang)"),
260
+ rate: readAttribute("rate"),
261
+ pitch: readAttribute("pitch")
262
+ });
263
+ let hash = 0xcbf29ce484222325n;
264
+ const mask = 0xffffffffffffffffn;
265
+ for (let index = 0; index < payload.length; index += 1) {
266
+ hash ^= BigInt(payload.charCodeAt(index));
267
+ hash = hash * 0x100000001b3n & mask;
268
+ }
269
+ return `fnv1a64-${hash.toString(16).padStart(16, "0")}`;
270
+ }
215
271
  function ascii(bytes, offset, value) {
216
272
  return [...value].every((character, index) => bytes[offset + index] === character.charCodeAt(0));
217
273
  }
@@ -251,9 +307,11 @@ function parseWav(buffer) {
251
307
  }
252
308
  return { chunks, data, format };
253
309
  }
254
- function formatNumber(format, pattern, fallback) {
255
- const match = pattern.exec(format);
256
- return match?.[1] ? Number(match[1]) : fallback;
310
+ function formatSampleRate(format) {
311
+ const match = /(?:^|-)(\d+)(khz|hz)(?:-|$)/i.exec(format);
312
+ if (!match?.[1] || !match[2]) return 0;
313
+ const value = Number(match[1]);
314
+ return match[2].toLowerCase() === "khz" ? value * 1e3 : value;
257
315
  }
258
316
  function formatChannels(format, fallback) {
259
317
  if (/stereo|2ch|dual/i.test(format)) return 2;
@@ -261,11 +319,13 @@ function formatChannels(format, fallback) {
261
319
  return fallback;
262
320
  }
263
321
  function formatAudioSpecification(format) {
264
- const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
322
+ const sampleRate = formatSampleRate(format);
265
323
  const channels = formatChannels(format, 0);
266
324
  const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
267
325
  const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1e3 : void 0;
268
- 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";
326
+ 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";
327
+ const bitDepthMatch = /(\d+)bit/i.exec(format);
328
+ const container = /(?:wav|wave|riff)/i.test(format) ? "riff-wave" : /mp3|mpeg/i.test(format) ? "mp3-raw" : /ogg/i.test(format) ? "ogg" : /webm/i.test(format) ? "webm" : /raw/i.test(format) ? "raw" : void 0;
269
329
  return {
270
330
  format,
271
331
  mimeType: resolveMimeType(format),
@@ -273,7 +333,10 @@ function formatAudioSpecification(format) {
273
333
  sampleRate,
274
334
  channels,
275
335
  ...bitrate ? { bitrate } : {},
276
- isCompressed: codec === "mp3" || codec === "opus" || codec === "silk"
336
+ ...bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {},
337
+ ...container ? { container } : {},
338
+ isVbr: /vbr/i.test(format),
339
+ isCompressed: codec !== "pcm" && codec !== "unknown"
277
340
  };
278
341
  }
279
342
  function parseMp3Specification(buffer, format) {
@@ -308,6 +371,8 @@ function parseMp3Specification(buffer, format) {
308
371
  sampleRate,
309
372
  channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
310
373
  bitrate: bitrateKbps * 1e3,
374
+ container: "mp3-raw",
375
+ isVbr: false,
311
376
  isCompressed: true
312
377
  };
313
378
  }
@@ -322,24 +387,43 @@ function inspectAudioSpecification(buffer, format) {
322
387
  const channels = view.getUint16(2, true);
323
388
  const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
324
389
  const formatCode = view.getUint16(0, true);
390
+ const namedCodec = formatAudioSpecification(format).codec;
391
+ const codec = formatCode === 1 ? "pcm" : formatCode === 6 ? "alaw" : formatCode === 7 ? "mulaw" : namedCodec === "siren" ? "siren" : "unknown";
325
392
  return {
326
393
  format,
327
394
  mimeType: "audio/wav",
328
- codec: formatCode === 1 ? "pcm" : "unknown",
395
+ codec,
329
396
  sampleRate,
330
397
  channels,
331
398
  ...sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {},
332
- isCompressed: formatCode !== 1
399
+ bitDepth: bitsPerSample,
400
+ container: "riff-wave",
401
+ isVbr: false,
402
+ isCompressed: codec !== "pcm" && codec !== "unknown"
333
403
  };
334
404
  }
335
405
  if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
336
- return formatAudioSpecification(format);
406
+ const specification = formatAudioSpecification(format);
407
+ if (specification.container === "raw") validateRawAudioBuffer(buffer, specification);
408
+ return specification;
409
+ }
410
+ function validateRawAudioBuffer(buffer, specification) {
411
+ if (specification.sampleRate <= 0 || specification.channels <= 0 || specification.bitDepth === void 0) {
412
+ throw new Error(`RAW audio format "${specification.format}" does not define a complete audio specification.`);
413
+ }
414
+ if (specification.codec === "siren" || specification.codec === "silk") return;
415
+ const bytesPerFrame = specification.channels * Math.ceil(specification.bitDepth / 8);
416
+ if (bytesPerFrame <= 0 || buffer.byteLength % bytesPerFrame !== 0) {
417
+ throw new Error(
418
+ `RAW audio buffer size ${buffer.byteLength} is not aligned to ${bytesPerFrame}-byte audio frames for "${specification.format}".`
419
+ );
420
+ }
337
421
  }
338
422
  function validateAudioSpecifications(specs) {
339
423
  const first = specs[0];
340
424
  if (!first) return;
341
425
  const mismatch = specs.find(
342
- (spec) => spec.sampleRate !== first.sampleRate || spec.channels !== first.channels || first.bitrate !== void 0 && spec.bitrate !== void 0 && spec.bitrate !== first.bitrate
426
+ (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
343
427
  );
344
428
  if (mismatch)
345
429
  throw new AudioFormatMismatchError(
@@ -422,6 +506,30 @@ function isWavFormat(format) {
422
506
  function isRawFormat(format) {
423
507
  return /^raw(?:-|$)/i.test(format);
424
508
  }
509
+ function validateMergedAudioBuffer(merged, format, buffers, inputSpecs, outputMimeType) {
510
+ if (!(merged instanceof ArrayBuffer) || merged.byteLength === 0) {
511
+ throw new MergeError("The custom audio merger returned an empty or invalid audio buffer.");
512
+ }
513
+ const specification = inspectAudioSpecification(merged, format);
514
+ if (!outputMimeType.trim()) throw new MergeError("The custom audio merger output MIME type cannot be empty.");
515
+ const firstInput = inputSpecs[0];
516
+ if (firstInput && (specification.sampleRate !== firstInput.sampleRate || specification.channels !== firstInput.channels || specification.codec !== firstInput.codec || firstInput.bitDepth !== void 0 && specification.bitDepth !== firstInput.bitDepth)) {
517
+ throw new AudioFormatMismatchError("The custom audio merger returned an incompatible audio stream.", [
518
+ ...inputSpecs,
519
+ specification
520
+ ]);
521
+ }
522
+ if (isRawFormat(format)) {
523
+ const expectedSize = buffers.reduce((total, input) => total + input.byteLength, 0);
524
+ if (merged.byteLength !== expectedSize) {
525
+ throw new MergeError(
526
+ `The custom audio merger returned ${merged.byteLength} bytes; ${expectedSize} were expected.`
527
+ );
528
+ }
529
+ validateRawAudioBuffer(merged, specification);
530
+ }
531
+ return specification;
532
+ }
425
533
  function resolveMergeAudioFormat(format) {
426
534
  if (isWavFormat(format)) return "wav";
427
535
  if (isMp3Format(format)) return "mp3";
@@ -474,7 +582,7 @@ function closeSpeechResources(speechConfig, synthesizer) {
474
582
  }
475
583
  }
476
584
  var ticksToMilliseconds = (ticks) => Math.max(0, ticks) / 1e4;
477
- async function synthesizeSsml(ssml, config) {
585
+ async function synthesizeSsmlOnce(ssml, config) {
478
586
  if (config.signal?.aborted) {
479
587
  throw new SynthesisCancelledError();
480
588
  }
@@ -542,9 +650,7 @@ async function synthesizeSsml(ssml, config) {
542
650
  };
543
651
  }
544
652
  if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) {
545
- const unmapped = { mappingStatus: "unmapped" };
546
- Object.defineProperty(unmapped, "mappingStatus", { value: "unmapped", enumerable: false });
547
- return unmapped;
653
+ return { mappingStatus: "unmapped" };
548
654
  }
549
655
  const value = text ?? "";
550
656
  let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? offsetHint : -1;
@@ -606,6 +712,13 @@ async function synthesizeSsml(ssml, config) {
606
712
  rejectWithError(err);
607
713
  return;
608
714
  }
715
+ let audioSpec;
716
+ try {
717
+ audioSpec = inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT);
718
+ } catch (error) {
719
+ rejectWithError(error);
720
+ return;
721
+ }
609
722
  settled = true;
610
723
  cleanup();
611
724
  closeResources();
@@ -626,8 +739,13 @@ async function synthesizeSsml(ssml, config) {
626
739
  ...config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
627
740
  ...requestId ? { requestId } : {}
628
741
  };
629
- if (event.mappingStatus === "unmapped")
742
+ if (event.mappingStatus === "unmapped") {
630
743
  Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
744
+ Object.defineProperty(mapped, "toJSON", {
745
+ value: () => ({ ...mapped, mappingStatus: "unmapped" }),
746
+ enumerable: false
747
+ });
748
+ }
631
749
  return mapped;
632
750
  };
633
751
  const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
@@ -636,8 +754,8 @@ async function synthesizeSsml(ssml, config) {
636
754
  resolve({
637
755
  audioData: result.audioData,
638
756
  durationMs,
639
- audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
640
- mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
757
+ audioSpec,
758
+ mimeType: config.outputMimeType ?? resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
641
759
  ...config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {},
642
760
  ...requestId ? { requestId } : {},
643
761
  ...sourceBoundaries.length > 0 ? { boundaries: sourceBoundaries, wordBoundary: sourceBoundaries, wordBoundaries: sourceBoundaries } : {},
@@ -650,10 +768,11 @@ async function synthesizeSsml(ssml, config) {
650
768
  abortHandler = () => rejectWithError(new SynthesisCancelledError());
651
769
  config.signal.addEventListener("abort", abortHandler, { once: true });
652
770
  }
653
- if (config.timeoutMs !== void 0 && config.timeoutMs > 0) {
771
+ const timeoutMs = config.timeouts?.perChunkMs ?? config.timeouts?.totalJobMs ?? config.timeoutMs;
772
+ if (timeoutMs !== void 0 && timeoutMs > 0) {
654
773
  timeout = setTimeout(
655
- () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeoutMs} ms.`)),
656
- config.timeoutMs
774
+ () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
775
+ timeoutMs
657
776
  );
658
777
  }
659
778
  synthesizer.speakSsmlAsync(ssml, cb, rejectWithError);
@@ -672,7 +791,9 @@ function isRetryableSynthesisError(error) {
672
791
  if (typeof status === "number") return status === 429 || status >= 500 && status < 600;
673
792
  return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
674
793
  }
675
- function retryDelay(options, retryAttempt) {
794
+ function retryDelay(options, retryAttempt, error) {
795
+ const retryAfterMs = getRetryAfterDelayMs(error);
796
+ if (retryAfterMs !== void 0) return retryAfterMs;
676
797
  const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
677
798
  return Math.floor(Math.random() * (base + 1));
678
799
  }
@@ -700,32 +821,102 @@ async function waitForRetry(delayMs, signal) {
700
821
  }
701
822
  });
702
823
  }
703
- async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
824
+ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry, deadlineAtMs) {
704
825
  const options = retryOptions ? {
705
826
  maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
706
827
  initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
707
- maxDelayMs: Math.max(0, retryOptions.maxDelayMs)
828
+ maxDelayMs: Math.max(0, retryOptions.maxDelayMs),
829
+ shouldRetry: retryOptions.shouldRetry
708
830
  } : void 0;
709
831
  let attempt = 0;
710
832
  while (true) {
711
833
  if (config.signal?.aborted) throw new SynthesisCancelledError();
712
834
  try {
713
- return await synthesizeSsml(ssml, config);
835
+ return await synthesizeSsmlOnce(ssml, config);
714
836
  } catch (error) {
715
- if (!options || attempt >= options.maxRetries || !isRetryableSynthesisError(error)) throw error;
837
+ if (!options || attempt >= options.maxRetries || !(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error)))
838
+ throw error;
716
839
  attempt += 1;
717
- const delayMs = retryDelay(options, attempt);
840
+ const delayMs = retryDelay(options, attempt, error);
841
+ const remainingMs = deadlineAtMs === void 0 ? void 0 : Math.max(0, deadlineAtMs - Date.now());
842
+ if (getRetryAfterDelayMs(error) !== void 0 && (delayMs > options.maxDelayMs || remainingMs !== void 0 && delayMs > remainingMs)) {
843
+ throw new SynthesisTimeoutError(
844
+ remainingMs === void 0 ? `Retry-After exceeded maxDelayMs (${options.maxDelayMs} ms).` : `Retry-After exceeded the remaining total job timeout (${remainingMs} ms).`
845
+ );
846
+ }
718
847
  onRetry(attempt, delayMs);
719
- await waitForRetry(delayMs, config.signal);
848
+ await waitForRetry(Math.min(delayMs, remainingMs ?? delayMs), config.signal);
720
849
  }
721
850
  }
722
851
  }
852
+ async function synthesizeSsml(ssml, config) {
853
+ const totalJobMs = config.timeouts?.totalJobMs;
854
+ const deadlineAtMs = totalJobMs !== void 0 && totalJobMs > 0 ? Date.now() + totalJobMs : void 0;
855
+ if (!config.retryOptions) return synthesizeSsmlOnce(ssml, config);
856
+ return synthesizeWithRetry(ssml, config, config.retryOptions, () => void 0, deadlineAtMs);
857
+ }
858
+ function createAbortScope(parent, timeoutMs) {
859
+ const controller = new AbortController();
860
+ let didTimeout = false;
861
+ const onAbort = () => controller.abort();
862
+ if (parent?.aborted) controller.abort();
863
+ parent?.addEventListener("abort", onAbort, { once: true });
864
+ const timer = timeoutMs !== void 0 && timeoutMs > 0 ? setTimeout(() => {
865
+ didTimeout = true;
866
+ controller.abort();
867
+ }, timeoutMs) : void 0;
868
+ return {
869
+ signal: controller.signal,
870
+ timedOut: () => didTimeout,
871
+ dispose: () => {
872
+ if (timer) clearTimeout(timer);
873
+ parent?.removeEventListener("abort", onAbort);
874
+ },
875
+ abort: () => controller.abort()
876
+ };
877
+ }
878
+ async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs, onRetry, deadlineAtMs) {
879
+ const scope = createAbortScope(config.signal, timeoutMs);
880
+ try {
881
+ return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry, deadlineAtMs);
882
+ } catch (error) {
883
+ if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
884
+ throw error;
885
+ } finally {
886
+ scope.dispose();
887
+ }
888
+ }
723
889
  async function synthesizeSsmlChunks(chunks, config) {
724
- const results = new Array(chunks.length);
725
890
  const totalChunks = chunks.length;
891
+ const inputs = chunks.map((chunk) => typeof chunk === "string" ? { ssml: chunk } : chunk);
892
+ const fingerprints = inputs.map(
893
+ (chunk) => computeChunkFingerprint(chunk.ssml, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT)
894
+ );
895
+ const results = new Array(totalChunks);
896
+ const cachedChunks = new Map((config.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
897
+ const invalidCachedIndices = /* @__PURE__ */ new Set();
898
+ const chunkStates = inputs.map((_chunk, chunkIndex) => ({
899
+ chunkIndex,
900
+ status: "pending",
901
+ canResume: true
902
+ }));
903
+ for (const [index, cached] of cachedChunks) {
904
+ if (index < 0 || index >= totalChunks) continue;
905
+ const isValid = config.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index];
906
+ if (isValid) {
907
+ results[index] = { ...cached };
908
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
909
+ } else {
910
+ invalidCachedIndices.add(index);
911
+ }
912
+ }
913
+ const requestedIndices = config.resumeChunkIndices ? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks)) : void 0;
914
+ const shouldSynthesize = (index) => (!cachedChunks.has(index) || invalidCachedIndices.has(index)) && (requestedIndices === void 0 || requestedIndices.has(index) || invalidCachedIndices.has(index));
915
+ const jobStartedAt = Date.now();
916
+ const jobDeadlineAt = config.timeouts?.totalJobMs !== void 0 && config.timeouts.totalJobMs > 0 ? jobStartedAt + config.timeouts.totalJobMs : void 0;
917
+ const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
726
918
  const report = (event) => config.onProgress?.(event);
727
- for (const [index, chunk] of chunks.entries()) {
728
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
919
+ for (const [index, input] of inputs.entries()) {
729
920
  report({
730
921
  currentChunk: index,
731
922
  totalChunks,
@@ -736,15 +927,18 @@ async function synthesizeSsmlChunks(chunks, config) {
736
927
  durationMs: 0
737
928
  });
738
929
  }
739
- let completed = 0;
930
+ let completed = [...results].filter((result) => result !== void 0).length;
740
931
  let nextIndex = 0;
741
932
  const concurrency = resolveConcurrency(config.concurrency, chunks.length);
933
+ let firstError;
934
+ const failedIndices = /* @__PURE__ */ new Set();
742
935
  const worker = async () => {
743
936
  while (true) {
744
937
  const index = nextIndex++;
745
938
  if (index >= chunks.length) return;
746
- const chunk = chunks[index];
747
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
939
+ if (!shouldSynthesize(index)) continue;
940
+ if (firstError && config.cancelOnFailure !== false) return;
941
+ const input = inputs[index];
748
942
  report({
749
943
  currentChunk: completed,
750
944
  totalChunks,
@@ -756,10 +950,11 @@ async function synthesizeSsmlChunks(chunks, config) {
756
950
  });
757
951
  const startedAt = Date.now();
758
952
  try {
759
- const result = await synthesizeWithRetry(
953
+ const result = await synthesizeChunkWithTimeout(
760
954
  input.ssml,
761
955
  {
762
956
  ...config,
957
+ signal: scope.signal,
763
958
  ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
764
959
  ...input.sourceNodePath ?? config.sourceNodePath ? { sourceNodePath: [...input.sourceNodePath ?? config.sourceNodePath ?? []] } : {},
765
960
  ...input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {},
@@ -768,6 +963,7 @@ async function synthesizeSsmlChunks(chunks, config) {
768
963
  onProgress: void 0
769
964
  },
770
965
  config.retryOptions,
966
+ config.timeouts?.chunkWithRetriesMs ?? config.timeouts?.perChunkMs ?? config.timeoutMs,
771
967
  (retryAttempt, nextRetryDelayMs) => report({
772
968
  currentChunk: completed,
773
969
  totalChunks,
@@ -779,9 +975,11 @@ async function synthesizeSsmlChunks(chunks, config) {
779
975
  retryAttempt,
780
976
  nextRetryDelayMs,
781
977
  isRetrying: true
782
- })
978
+ }),
979
+ jobDeadlineAt
783
980
  );
784
981
  results[index] = result;
982
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result };
785
983
  completed += 1;
786
984
  report({
787
985
  currentChunk: completed,
@@ -793,6 +991,16 @@ async function synthesizeSsmlChunks(chunks, config) {
793
991
  durationMs: Date.now() - startedAt
794
992
  });
795
993
  } catch (error) {
994
+ const wasCancelled = firstError !== void 0 || scope.signal.aborted && !scope.timedOut();
995
+ firstError ?? (firstError = scope.timedOut() ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`) : error);
996
+ if (!wasCancelled) failedIndices.add(index);
997
+ chunkStates[index] = {
998
+ chunkIndex: index,
999
+ status: wasCancelled ? "cancelled" : "failed",
1000
+ isOriginalFailure: !wasCancelled,
1001
+ canResume: true,
1002
+ error
1003
+ };
796
1004
  report({
797
1005
  currentChunk: completed,
798
1006
  totalChunks,
@@ -803,16 +1011,49 @@ async function synthesizeSsmlChunks(chunks, config) {
803
1011
  durationMs: Date.now() - startedAt,
804
1012
  error
805
1013
  });
806
- throw error;
1014
+ if (config.cancelOnFailure !== false) scope.abort();
1015
+ return;
807
1016
  }
808
1017
  }
809
1018
  };
810
- await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
811
- const orderedResults = results.filter((result) => result !== void 0);
812
- return mergeSynthesisResults(orderedResults, {
813
- format: config.outputFormat ?? DEFAULT_OUTPUT_FORMAT,
814
- signal: config.signal
815
- });
1019
+ try {
1020
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
1021
+ if (firstError) throw firstError;
1022
+ const orderedResults = results.filter((result) => result !== void 0);
1023
+ return await mergeSynthesisResults(orderedResults, {
1024
+ format: config.outputFormat ?? DEFAULT_OUTPUT_FORMAT,
1025
+ signal: scope.signal,
1026
+ customMerger: config.customMerger,
1027
+ outputMimeType: config.outputMimeType,
1028
+ postMergeValidator: config.postMergeValidator
1029
+ });
1030
+ } catch (error) {
1031
+ if (firstError && config.cancelOnFailure !== false) {
1032
+ for (const [chunkIndex, state] of chunkStates.entries()) {
1033
+ if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
1034
+ chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
1035
+ }
1036
+ }
1037
+ }
1038
+ const synthesizedChunks = results.flatMap(
1039
+ (result, chunkIndex) => result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : []
1040
+ );
1041
+ const partial = {
1042
+ synthesizedChunks,
1043
+ completedChunks: synthesizedChunks,
1044
+ pendingChunkIndices: chunkStates.flatMap(
1045
+ (state) => state.status === "pending" || state.status === "cancelled" || state.status === "failed" ? [state.chunkIndex] : []
1046
+ ),
1047
+ failedChunkIndices: [...failedIndices],
1048
+ cancelledChunkIndices: chunkStates.filter((state) => state.status === "cancelled").map((state) => state.chunkIndex),
1049
+ chunkStates,
1050
+ totalChunks
1051
+ };
1052
+ if (error && typeof error === "object") error.partialResult = partial;
1053
+ throw error;
1054
+ } finally {
1055
+ scope.dispose();
1056
+ }
816
1057
  }
817
1058
  function createMergedResult(results, audioData, format, audioSpec, outputMimeType) {
818
1059
  const boundaries = [];
@@ -903,16 +1144,26 @@ function mergeSynthesisResults(results, options) {
903
1144
  })
904
1145
  ).then((merged) => {
905
1146
  if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
906
- if (!(merged instanceof ArrayBuffer) || buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
907
- throw new MergeError("The custom audio merger returned an invalid audio buffer.");
908
1147
  if (signal.aborted) throw new SynthesisCancelledError();
909
- return createMergedResult(
910
- results,
1148
+ const mergedSpec = validateMergedAudioBuffer(
911
1149
  merged,
912
1150
  format,
913
- inspectAudioSpecification(merged, format),
914
- resolvedOptions.outputMimeType
1151
+ buffers,
1152
+ inputSpecs,
1153
+ resolvedOptions.outputMimeType ?? resolveMimeType(format)
915
1154
  );
1155
+ const result = createMergedResult(results, merged, format, mergedSpec, resolvedOptions.outputMimeType);
1156
+ return Promise.resolve(
1157
+ resolvedOptions.postMergeValidator?.(result, {
1158
+ format,
1159
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
1160
+ inputSpecs,
1161
+ signal
1162
+ })
1163
+ ).then((valid) => {
1164
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1165
+ return result;
1166
+ });
916
1167
  }).catch((error) => {
917
1168
  if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
918
1169
  throw error;
@@ -920,13 +1171,28 @@ function mergeSynthesisResults(results, options) {
920
1171
  });
921
1172
  }
922
1173
  try {
923
- return createMergedResult(
1174
+ const result = createMergedResult(
924
1175
  results,
925
1176
  mergeAudioBuffers(buffers, { format }),
926
1177
  format,
927
1178
  inputSpecs[0],
928
1179
  resolvedOptions.outputMimeType
929
1180
  );
1181
+ if (resolvedOptions.postMergeValidator) {
1182
+ const validation = resolvedOptions.postMergeValidator(result, {
1183
+ format,
1184
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
1185
+ inputSpecs,
1186
+ signal
1187
+ });
1188
+ if (validation instanceof Promise)
1189
+ return validation.then((valid) => {
1190
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1191
+ return result;
1192
+ });
1193
+ if (validation === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1194
+ }
1195
+ return result;
930
1196
  } catch (error) {
931
1197
  if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
932
1198
  throw error;
@@ -948,8 +1214,52 @@ var ChunkValidationError = class extends Error {
948
1214
  this.diagnostics = diagnostics;
949
1215
  }
950
1216
  };
951
- function failure(error) {
952
- return { ok: false, success: false, status: error.kind, error };
1217
+ var BatchChunkValidationError = class extends ChunkValidationError {
1218
+ constructor(chunkDiagnostics) {
1219
+ const first = chunkDiagnostics[0];
1220
+ super(first?.chunkIndex ?? -1, first?.diagnostics ?? []);
1221
+ this.name = "BatchChunkValidationError";
1222
+ this.message = `SSML validation failed for ${chunkDiagnostics.length} chunk(s); the Azure Speech API was not called.`;
1223
+ this.chunkDiagnostics = chunkDiagnostics;
1224
+ this.totalErrorCount = chunkDiagnostics.reduce((total, chunk) => total + chunk.diagnostics.length, 0);
1225
+ this.errorCount = this.totalErrorCount;
1226
+ this.totalErrors = this.totalErrorCount;
1227
+ }
1228
+ };
1229
+ function failure(error, partialResult) {
1230
+ return {
1231
+ ok: false,
1232
+ success: false,
1233
+ status: error.kind,
1234
+ error,
1235
+ ...partialResult ? { partialResult } : {}
1236
+ };
1237
+ }
1238
+ function partialResultFrom(error) {
1239
+ if (!error || typeof error !== "object") return void 0;
1240
+ const partial = error.partialResult;
1241
+ if (!partial || typeof partial !== "object") return void 0;
1242
+ return partial;
1243
+ }
1244
+ function createSafeAbortScope(parent, timeoutMs) {
1245
+ const controller = new AbortController();
1246
+ let didTimeout = false;
1247
+ const onAbort = () => controller.abort();
1248
+ if (parent?.aborted) controller.abort();
1249
+ parent?.addEventListener("abort", onAbort, { once: true });
1250
+ const timer = timeoutMs !== void 0 && timeoutMs > 0 ? setTimeout(() => {
1251
+ didTimeout = true;
1252
+ controller.abort();
1253
+ }, timeoutMs) : void 0;
1254
+ return {
1255
+ signal: controller.signal,
1256
+ timedOut: () => didTimeout,
1257
+ dispose: () => {
1258
+ if (timer) clearTimeout(timer);
1259
+ parent?.removeEventListener("abort", onAbort);
1260
+ },
1261
+ abort: () => controller.abort()
1262
+ };
953
1263
  }
954
1264
  function isRetryable(error) {
955
1265
  if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
@@ -964,16 +1274,20 @@ function delayForRetry(options, attempt) {
964
1274
  const base = Math.min(maxDelay, Math.max(0, options.initialDelayMs) * 2 ** Math.max(0, attempt - 1));
965
1275
  return Math.floor(Math.random() * (base + 1));
966
1276
  }
1277
+ function retryDelayForError(options, attempt, error) {
1278
+ return getRetryAfterDelayMs(error) ?? delayForRetry(options, attempt);
1279
+ }
967
1280
  function resolveConcurrency2(value, total) {
968
1281
  if (value === void 0) return 1;
969
1282
  if (value === Infinity) return Math.max(1, total);
970
1283
  return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
971
1284
  }
972
- async function retryableSynthesis(synthesize, options, signal, onRetry) {
1285
+ async function retryableSynthesis(synthesize, options, signal, onRetry, deadlineAtMs) {
973
1286
  const retry = options ? {
974
1287
  maxRetries: Math.max(0, Math.floor(options.maxRetries)),
975
1288
  initialDelayMs: options.initialDelayMs,
976
- maxDelayMs: options.maxDelayMs
1289
+ maxDelayMs: options.maxDelayMs,
1290
+ shouldRetry: options.shouldRetry
977
1291
  } : void 0;
978
1292
  let attempt = 0;
979
1293
  while (true) {
@@ -981,9 +1295,15 @@ async function retryableSynthesis(synthesize, options, signal, onRetry) {
981
1295
  try {
982
1296
  return await synthesize();
983
1297
  } catch (error) {
984
- if (!retry || attempt >= retry.maxRetries || !isRetryable(error)) throw error;
1298
+ if (!retry || attempt >= retry.maxRetries || !(retry.shouldRetry?.(error, attempt + 1) ?? isRetryable(error)))
1299
+ throw error;
985
1300
  attempt += 1;
986
- const delayMs = delayForRetry(retry, attempt);
1301
+ const delayMs = retryDelayForError(retry, attempt, error);
1302
+ const retryAfterMs = getRetryAfterDelayMs(error);
1303
+ const remainingMs = deadlineAtMs === void 0 ? void 0 : Math.max(0, deadlineAtMs - Date.now());
1304
+ if (retryAfterMs !== void 0 && (retryAfterMs > retry.maxDelayMs || remainingMs !== void 0 && retryAfterMs > remainingMs)) {
1305
+ throw new Error("Speech synthesis timed out because Retry-After exceeded the available retry budget.");
1306
+ }
987
1307
  onRetry(attempt, delayMs);
988
1308
  if (delayMs > 0)
989
1309
  await new Promise((resolve, reject) => {
@@ -1007,7 +1327,7 @@ function sharedValidationOptions(options, signal) {
1007
1327
  const runner = (0, import_ssml_core2.createAzureUrlValidatorRunner)(validator, {
1008
1328
  ...options.urlValidation ?? {},
1009
1329
  ...options.urlValidatorConcurrency !== void 0 ? { concurrency: options.urlValidatorConcurrency } : {},
1010
- ...options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
1330
+ ...options.timeouts?.urlValidationMs !== void 0 ? { timeoutMs: options.timeouts.urlValidationMs } : options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
1011
1331
  ...signal ? { signal } : {},
1012
1332
  ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
1013
1333
  });
@@ -1031,20 +1351,31 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
1031
1351
  diagnostics: errors
1032
1352
  });
1033
1353
  }
1354
+ const jobScope = options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts.totalJobMs) : void 0;
1034
1355
  try {
1035
1356
  return {
1036
1357
  ok: true,
1037
1358
  success: true,
1038
1359
  status: "success",
1039
- value: await client.synthesizeSsml(ssml, { signal: options.signal })
1360
+ value: await client.synthesizeSsml(ssml, {
1361
+ signal: jobScope?.signal ?? options.signal,
1362
+ timeoutMs: options.timeouts?.perChunkMs,
1363
+ timeouts: options.timeouts
1364
+ })
1040
1365
  };
1041
1366
  } catch (error) {
1367
+ if (jobScope?.timedOut()) return failure(toSynthesisError(new Error("Speech synthesis timed out.")));
1042
1368
  const synthesisError = toSynthesisError(error);
1043
1369
  return failure(synthesisError);
1370
+ } finally {
1371
+ jobScope?.dispose();
1044
1372
  }
1045
1373
  }
1046
1374
  async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1047
- const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
1375
+ const validationOptions = sharedValidationOptions(
1376
+ { ...options.validation ?? options, timeouts: options.timeouts },
1377
+ options.signal
1378
+ );
1048
1379
  if (options.signal?.aborted) {
1049
1380
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
1050
1381
  return failure(error);
@@ -1078,16 +1409,17 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1078
1409
  return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
1079
1410
  })
1080
1411
  );
1081
- const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
1082
1412
  if (options.signal?.aborted) {
1083
1413
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
1084
1414
  return failure(error);
1085
1415
  }
1086
- if (firstInvalidIndex >= 0) {
1087
- const error = new ChunkValidationError(firstInvalidIndex, validations[firstInvalidIndex] ?? []);
1088
- pending(firstInvalidIndex, "failed", error);
1416
+ const chunkDiagnostics = validations.map((diagnostics, chunkIndex) => ({ chunkIndex, diagnostics })).filter((entry) => entry.diagnostics.length > 0);
1417
+ if (chunkDiagnostics.length > 0) {
1418
+ const error = new BatchChunkValidationError(chunkDiagnostics);
1419
+ for (const entry of chunkDiagnostics) pending(entry.chunkIndex, "failed", error);
1089
1420
  return failure(error);
1090
1421
  }
1422
+ let fallbackJobScope;
1091
1423
  try {
1092
1424
  if (client.synthesizeChunks) {
1093
1425
  const normalizedChunks = chunks.map((chunk) => {
@@ -1099,20 +1431,57 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1099
1431
  outputFormat: options.outputFormat,
1100
1432
  signal: options.signal,
1101
1433
  timeoutMs: options.timeoutMs,
1434
+ timeouts: options.timeouts,
1102
1435
  sourceNodePath: options.sourceNodePath,
1103
1436
  concurrency: options.concurrency,
1104
- retryOptions: options.retryOptions
1437
+ retryOptions: options.retryOptions,
1438
+ cancelOnFailure: options.cancelOnFailure,
1439
+ resumeChunks: options.resumeChunks,
1440
+ resumeChunkIndices: options.resumeChunkIndices,
1441
+ customMerger: options.customMerger,
1442
+ outputMimeType: options.outputMimeType,
1443
+ postMergeValidator: options.postMergeValidator,
1444
+ resumeValidation: options.resumeValidation
1105
1445
  });
1106
1446
  return { ok: true, success: true, status: "success", value };
1107
1447
  }
1448
+ const inputs = chunks.map((chunk) => typeof chunk === "string" ? { ssml: chunk } : chunk);
1449
+ const fingerprints = inputs.map((chunk) => computeChunkFingerprint(chunk.ssml, options.outputFormat));
1108
1450
  const results = new Array(chunks.length);
1109
- let completed = 0;
1451
+ const chunkStates = inputs.map((_chunk, chunkIndex) => ({
1452
+ chunkIndex,
1453
+ status: "pending",
1454
+ canResume: true
1455
+ }));
1456
+ const cachedChunks = new Map((options.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
1457
+ const invalidCachedIndices = /* @__PURE__ */ new Set();
1458
+ for (const [index, cached] of cachedChunks) {
1459
+ if (index < 0 || index >= chunks.length) continue;
1460
+ if (options.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index]) {
1461
+ results[index] = cached;
1462
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: cached };
1463
+ } else invalidCachedIndices.add(index);
1464
+ }
1465
+ const requestedIndices = options.resumeChunkIndices ? new Set(options.resumeChunkIndices.filter((index) => index >= 0 && index < chunks.length)) : void 0;
1466
+ const shouldSynthesize = (index) => (!cachedChunks.has(index) || invalidCachedIndices.has(index)) && (requestedIndices === void 0 || requestedIndices.has(index) || invalidCachedIndices.has(index));
1467
+ const jobStartedAt = Date.now();
1468
+ const jobDeadlineAt = options.timeouts?.totalJobMs !== void 0 && options.timeouts.totalJobMs > 0 ? jobStartedAt + options.timeouts.totalJobMs : void 0;
1469
+ const jobScope = chunks.length > 1 || options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs) : void 0;
1470
+ fallbackJobScope = jobScope;
1471
+ const failedIndices = /* @__PURE__ */ new Set();
1472
+ let firstError;
1473
+ let completed = [...results].filter((result) => result !== void 0).length;
1110
1474
  let nextIndex = 0;
1111
1475
  const concurrency = resolveConcurrency2(options.concurrency, chunks.length);
1112
1476
  const worker = async () => {
1113
1477
  while (true) {
1114
1478
  const index = nextIndex++;
1115
1479
  if (index >= chunks.length) return;
1480
+ if (!shouldSynthesize(index)) continue;
1481
+ if (firstError && options.cancelOnFailure !== false) {
1482
+ chunkStates[index] = { chunkIndex: index, status: "cancelled", isOriginalFailure: false, canResume: true };
1483
+ return;
1484
+ }
1116
1485
  const chunk = chunks[index];
1117
1486
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
1118
1487
  const sourceNodePath = input.sourceNodePath;
@@ -1120,28 +1489,41 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1120
1489
  pending(index, "synthesizing");
1121
1490
  const startedAt = Date.now();
1122
1491
  try {
1123
- const result = await retryableSynthesis(
1124
- () => client.synthesizeSsml(input.ssml, {
1125
- outputFormat: options.outputFormat,
1126
- signal: options.signal,
1127
- timeoutMs: options.timeoutMs,
1128
- sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
1129
- }),
1130
- options.retryOptions,
1131
- options.signal,
1132
- (retryAttempt, nextRetryDelayMs) => options.onProgress?.({
1133
- currentChunk: completed,
1134
- totalChunks: chunks.length,
1135
- percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1136
- chunkIndex: index,
1137
- originalTextRange: input.originalTextRange,
1138
- status: "synthesizing",
1139
- durationMs: Date.now() - startedAt,
1140
- retryAttempt,
1141
- nextRetryDelayMs,
1142
- isRetrying: true
1143
- })
1144
- );
1492
+ const chunkTimeout = options.timeouts?.chunkWithRetriesMs ?? options.timeouts?.perChunkMs;
1493
+ const chunkScope = chunkTimeout !== void 0 || jobScope ? createSafeAbortScope(jobScope?.signal ?? options.signal, chunkTimeout ?? options.timeoutMs) : void 0;
1494
+ const chunkSignal = chunkScope?.signal ?? options.signal;
1495
+ let result;
1496
+ try {
1497
+ result = await retryableSynthesis(
1498
+ () => client.synthesizeSsml(input.ssml, {
1499
+ outputFormat: options.outputFormat,
1500
+ signal: chunkSignal,
1501
+ timeoutMs: options.timeouts?.perChunkMs ?? options.timeoutMs,
1502
+ sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
1503
+ }),
1504
+ options.retryOptions,
1505
+ chunkSignal,
1506
+ (retryAttempt, nextRetryDelayMs) => options.onProgress?.({
1507
+ currentChunk: completed,
1508
+ totalChunks: chunks.length,
1509
+ percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1510
+ chunkIndex: index,
1511
+ originalTextRange: input.originalTextRange,
1512
+ status: "synthesizing",
1513
+ durationMs: Date.now() - startedAt,
1514
+ retryAttempt,
1515
+ nextRetryDelayMs,
1516
+ isRetrying: true
1517
+ }),
1518
+ jobDeadlineAt
1519
+ );
1520
+ } catch (error) {
1521
+ if (chunkScope?.timedOut())
1522
+ throw new Error(`Speech synthesis timed out after ${chunkTimeout ?? options.timeoutMs} ms.`);
1523
+ throw error;
1524
+ } finally {
1525
+ chunkScope?.dispose();
1526
+ }
1145
1527
  results[index] = {
1146
1528
  ...result,
1147
1529
  ...input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {},
@@ -1185,6 +1567,7 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1185
1567
  }))
1186
1568
  } : {}
1187
1569
  };
1570
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
1188
1571
  completed += 1;
1189
1572
  options.onProgress?.({
1190
1573
  currentChunk: completed,
@@ -1196,6 +1579,16 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1196
1579
  durationMs: Date.now() - startedAt
1197
1580
  });
1198
1581
  } catch (error) {
1582
+ const wasCancelled = firstError !== void 0 || Boolean(jobScope?.signal.aborted && !jobScope?.timedOut());
1583
+ firstError ?? (firstError = error);
1584
+ if (!wasCancelled) failedIndices.add(index);
1585
+ chunkStates[index] = {
1586
+ chunkIndex: index,
1587
+ status: wasCancelled ? "cancelled" : "failed",
1588
+ isOriginalFailure: !wasCancelled,
1589
+ canResume: true,
1590
+ error
1591
+ };
1199
1592
  options.onProgress?.({
1200
1593
  currentChunk: completed,
1201
1594
  totalChunks: chunks.length,
@@ -1206,24 +1599,55 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1206
1599
  durationMs: Date.now() - startedAt,
1207
1600
  error
1208
1601
  });
1209
- throw error;
1602
+ if (options.cancelOnFailure !== false) jobScope?.abort();
1603
+ return;
1210
1604
  }
1211
1605
  }
1212
1606
  };
1213
1607
  await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
1608
+ if (firstError && options.cancelOnFailure !== false) {
1609
+ for (const [chunkIndex, state] of chunkStates.entries()) {
1610
+ if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
1611
+ chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
1612
+ }
1613
+ }
1614
+ }
1615
+ if (failedIndices.size > 0) {
1616
+ const error = firstError ?? new Error("One or more SSML chunks failed to synthesize.");
1617
+ const synthesizedChunks = results.flatMap(
1618
+ (result, chunkIndex) => result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : []
1619
+ );
1620
+ error.partialResult = {
1621
+ synthesizedChunks,
1622
+ completedChunks: synthesizedChunks,
1623
+ pendingChunkIndices: chunkStates.flatMap(
1624
+ (state) => state.status === "pending" || state.status === "cancelled" || state.status === "failed" ? [state.chunkIndex] : []
1625
+ ),
1626
+ failedChunkIndices: [...failedIndices],
1627
+ cancelledChunkIndices: chunkStates.filter((state) => state.status === "cancelled").map((state) => state.chunkIndex),
1628
+ chunkStates,
1629
+ totalChunks: chunks.length
1630
+ };
1631
+ throw error;
1632
+ }
1214
1633
  const orderedResults = results.filter((result) => result !== void 0);
1215
1634
  return {
1216
1635
  ok: true,
1217
1636
  success: true,
1218
1637
  status: "success",
1219
- value: mergeSynthesisResults(orderedResults, {
1638
+ value: await mergeSynthesisResults(orderedResults, {
1220
1639
  format: options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3",
1221
- signal: options.signal
1640
+ signal: jobScope?.signal ?? options.signal,
1641
+ customMerger: options.customMerger,
1642
+ outputMimeType: options.outputMimeType,
1643
+ postMergeValidator: options.postMergeValidator
1222
1644
  })
1223
1645
  };
1224
1646
  } catch (error) {
1225
1647
  const synthesisError = toSynthesisError(error);
1226
- return failure(synthesisError);
1648
+ return failure(synthesisError, partialResultFrom(error));
1649
+ } finally {
1650
+ fallbackJobScope?.dispose();
1227
1651
  }
1228
1652
  }
1229
1653
  function withValidationSignal(options, signal) {
@@ -1244,14 +1668,23 @@ var AzureTtsClient = class {
1244
1668
  __privateSet(this, _options, options);
1245
1669
  }
1246
1670
  async synthesize(ssml) {
1247
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1671
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
1248
1672
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
1249
1673
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
1250
- const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs };
1674
+ const config = {
1675
+ endpoint,
1676
+ region,
1677
+ subscriptionKey,
1678
+ outputFormat,
1679
+ signal,
1680
+ timeoutMs,
1681
+ timeouts,
1682
+ retryOptions: __privateGet(this, _options).retryOptions
1683
+ };
1251
1684
  return synthesizeSpeech(ssml, config);
1252
1685
  }
1253
1686
  async synthesizeSsml(ssml, options = {}) {
1254
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1687
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
1255
1688
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
1256
1689
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
1257
1690
  return synthesizeSsml(ssml, {
@@ -1261,13 +1694,20 @@ var AzureTtsClient = class {
1261
1694
  outputFormat: options.outputFormat ?? outputFormat,
1262
1695
  signal: options.signal ?? signal,
1263
1696
  timeoutMs: options.timeoutMs ?? timeoutMs,
1697
+ timeouts: options.timeouts ?? timeouts,
1264
1698
  sourceNodePath: options.sourceNodePath,
1265
1699
  sourceTextSegments: options.sourceTextSegments,
1266
- sourceMarkers: options.sourceMarkers
1700
+ sourceMarkers: options.sourceMarkers,
1701
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
1702
+ cancelOnFailure: options.cancelOnFailure ?? __privateGet(this, _options).cancelOnFailure,
1703
+ customMerger: options.customMerger ?? __privateGet(this, _options).customMerger,
1704
+ outputMimeType: options.outputMimeType ?? __privateGet(this, _options).outputMimeType,
1705
+ postMergeValidator: options.postMergeValidator ?? __privateGet(this, _options).postMergeValidator,
1706
+ resumeValidation: options.resumeValidation ?? __privateGet(this, _options).resumeValidation
1267
1707
  });
1268
1708
  }
1269
1709
  async synthesizeChunks(chunks, options = {}) {
1270
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1710
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
1271
1711
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
1272
1712
  return synthesizeSsmlChunks(chunks, {
1273
1713
  endpoint,
@@ -1276,10 +1716,18 @@ var AzureTtsClient = class {
1276
1716
  outputFormat: options.outputFormat ?? outputFormat,
1277
1717
  signal: options.signal ?? signal,
1278
1718
  timeoutMs: options.timeoutMs ?? timeoutMs,
1719
+ timeouts: options.timeouts ?? timeouts,
1279
1720
  sourceNodePath: options.sourceNodePath,
1280
1721
  onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1281
1722
  concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1282
- retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
1723
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
1724
+ cancelOnFailure: options.cancelOnFailure ?? __privateGet(this, _options).cancelOnFailure,
1725
+ resumeChunks: options.resumeChunks,
1726
+ resumeChunkIndices: options.resumeChunkIndices,
1727
+ customMerger: options.customMerger ?? __privateGet(this, _options).customMerger,
1728
+ outputMimeType: options.outputMimeType ?? __privateGet(this, _options).outputMimeType,
1729
+ postMergeValidator: options.postMergeValidator ?? __privateGet(this, _options).postMergeValidator,
1730
+ resumeValidation: options.resumeValidation ?? __privateGet(this, _options).resumeValidation
1283
1731
  });
1284
1732
  }
1285
1733
  async synthesizeSsmlSafe(ssml, options = {}) {
@@ -1291,6 +1739,7 @@ var AzureTtsClient = class {
1291
1739
  outputFormat: options.outputFormat ?? __privateGet(this, _options).outputFormat,
1292
1740
  signal: options.signal ?? __privateGet(this, _options).signal,
1293
1741
  timeoutMs: options.timeoutMs ?? __privateGet(this, _options).timeoutMs,
1742
+ timeouts: options.timeouts ?? __privateGet(this, _options).timeouts,
1294
1743
  onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1295
1744
  concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1296
1745
  retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
@@ -1384,7 +1833,9 @@ async function fetchAzureVoiceCatalog(options) {
1384
1833
  voiceCount: sortedVoices.length,
1385
1834
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1386
1835
  apiVersion: AZURE_VOICE_API_VERSION,
1387
- regions
1836
+ regions,
1837
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1e3).toISOString(),
1838
+ regionDiffs: {}
1388
1839
  }
1389
1840
  };
1390
1841
  }
@@ -1394,6 +1845,7 @@ async function fetchAzureVoiceCatalog(options) {
1394
1845
  AzureTtsClient,
1395
1846
  AzureTtsError,
1396
1847
  AzureTtsSdkError,
1848
+ BatchChunkValidationError,
1397
1849
  ChunkValidationError,
1398
1850
  DEFAULT_OUTPUT_FORMAT,
1399
1851
  MergeError,
@@ -1401,7 +1853,9 @@ async function fetchAzureVoiceCatalog(options) {
1401
1853
  SynthesisTimeoutError,
1402
1854
  UnsupportedMergeFormatError,
1403
1855
  canMergeAudioFormat,
1856
+ computeChunkFingerprint,
1404
1857
  fetchAzureVoiceCatalog,
1858
+ getRetryAfterDelayMs,
1405
1859
  inspectAudioSpecification,
1406
1860
  mergeAudioBuffers,
1407
1861
  mergeSynthesisResults,