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.mjs CHANGED
@@ -18,12 +18,12 @@ import {
18
18
  validateAzureSsmlChunks,
19
19
  validateSsml,
20
20
  validateSsmlStructureIntegrity
21
- } from "./chunk-NKLZGITR.mjs";
21
+ } from "./chunk-2SYLELUT.mjs";
22
22
  import {
23
23
  createAzureUrlValidatorRunner as createAzureUrlValidatorRunner2,
24
24
  getSsmlSourceMap as getSsmlSourceMap2,
25
25
  validateAzureSsml as validateAzureSsml2
26
- } from "./chunk-HI74FTKY.mjs";
26
+ } from "./chunk-5AZWURHW.mjs";
27
27
  import {
28
28
  __privateAdd,
29
29
  __privateGet,
@@ -32,7 +32,7 @@ import {
32
32
 
33
33
  // packages/azure-tts-client/src/errors.ts
34
34
  var AzureTtsError = class extends Error {
35
- constructor(status, statusText, responseBody, requestId) {
35
+ constructor(status, statusText, responseBody, requestId, responseHeaders) {
36
36
  super(`Azure TTS request failed: ${status} ${statusText}`);
37
37
  this.kind = "azure-api-error";
38
38
  this.name = "AzureTtsError";
@@ -40,8 +40,37 @@ var AzureTtsError = class extends Error {
40
40
  this.statusText = statusText;
41
41
  this.responseBody = responseBody;
42
42
  this.requestId = requestId;
43
+ const value = responseHeaders instanceof Headers ? responseHeaders.get("retry-after") : responseHeaders?.["retry-after"] ?? responseHeaders?.["Retry-After"];
44
+ const seconds = value ? Number(value.trim()) : NaN;
45
+ const date = value ? Date.parse(value) : NaN;
46
+ if (Number.isFinite(seconds) && seconds >= 0) this.retryAfterMs = seconds * 1e3;
47
+ else if (Number.isFinite(date)) this.retryAfterMs = Math.max(0, date - Date.now());
43
48
  }
44
49
  };
50
+ function getRetryAfterDelayMs(error) {
51
+ if (error instanceof AzureTtsError && error.retryAfterMs !== void 0) return error.retryAfterMs;
52
+ if (!error || typeof error !== "object") return void 0;
53
+ const candidate = error;
54
+ if (typeof candidate.retryAfterMs === "number" && candidate.retryAfterMs >= 0) return candidate.retryAfterMs;
55
+ const headers = candidate.headers ?? candidate.response?.headers;
56
+ if (headers instanceof Headers) {
57
+ const value = headers.get("retry-after");
58
+ if (!value) return void 0;
59
+ const seconds = Number(value.trim());
60
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
61
+ const date = Date.parse(value);
62
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : void 0;
63
+ }
64
+ if (headers && typeof headers === "object") {
65
+ const value = headers["retry-after"] ?? headers["Retry-After"];
66
+ if (typeof value !== "string") return void 0;
67
+ const seconds = Number(value.trim());
68
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
69
+ const date = Date.parse(value);
70
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : void 0;
71
+ }
72
+ return void 0;
73
+ }
45
74
  var AzureTtsSdkError = class extends AzureTtsError {
46
75
  constructor(errorDetails) {
47
76
  super(0, "Speech SDK", errorDetails, null);
@@ -151,6 +180,9 @@ var OUTPUT_FORMATS = {
151
180
  function resolveMimeType(outputFormat) {
152
181
  if (/(?:wav|wave|riff)/i.test(outputFormat)) return "audio/wav";
153
182
  if (/(?:mp3|mpeg)/i.test(outputFormat)) return "audio/mpeg";
183
+ if (/mulaw|mu-law/i.test(outputFormat)) return "audio/basic";
184
+ if (/alaw|a-law/i.test(outputFormat)) return "audio/alaw";
185
+ if (/siren/i.test(outputFormat)) return "audio/siren";
154
186
  if (/ogg/i.test(outputFormat)) return "audio/ogg";
155
187
  if (/webm/i.test(outputFormat)) return "audio/webm";
156
188
  if (/raw/i.test(outputFormat)) return "audio/L16";
@@ -179,6 +211,27 @@ function createSpeechConfig(config) {
179
211
  }
180
212
 
181
213
  // packages/azure-tts-client/src/synthesis.ts
214
+ function computeChunkFingerprint(ssml, outputFormat = DEFAULT_OUTPUT_FORMAT) {
215
+ const readAttribute = (name) => {
216
+ const pattern = new RegExp(`(?:${name})\\s*=\\s*[\\"']([^\\"']*)`, "gi");
217
+ return [...ssml.matchAll(pattern)].map((match) => match[1] ?? "").join("|");
218
+ };
219
+ const payload = JSON.stringify({
220
+ ssml,
221
+ outputFormat,
222
+ voice: readAttribute("(?:name|voice)"),
223
+ language: readAttribute("(?:xml:lang|lang)"),
224
+ rate: readAttribute("rate"),
225
+ pitch: readAttribute("pitch")
226
+ });
227
+ let hash = 0xcbf29ce484222325n;
228
+ const mask = 0xffffffffffffffffn;
229
+ for (let index = 0; index < payload.length; index += 1) {
230
+ hash ^= BigInt(payload.charCodeAt(index));
231
+ hash = hash * 0x100000001b3n & mask;
232
+ }
233
+ return `fnv1a64-${hash.toString(16).padStart(16, "0")}`;
234
+ }
182
235
  function ascii(bytes, offset, value) {
183
236
  return [...value].every((character, index) => bytes[offset + index] === character.charCodeAt(0));
184
237
  }
@@ -218,9 +271,11 @@ function parseWav(buffer) {
218
271
  }
219
272
  return { chunks, data, format };
220
273
  }
221
- function formatNumber(format, pattern, fallback) {
222
- const match = pattern.exec(format);
223
- return match?.[1] ? Number(match[1]) : fallback;
274
+ function formatSampleRate(format) {
275
+ const match = /(?:^|-)(\d+)(khz|hz)(?:-|$)/i.exec(format);
276
+ if (!match?.[1] || !match[2]) return 0;
277
+ const value = Number(match[1]);
278
+ return match[2].toLowerCase() === "khz" ? value * 1e3 : value;
224
279
  }
225
280
  function formatChannels(format, fallback) {
226
281
  if (/stereo|2ch|dual/i.test(format)) return 2;
@@ -228,11 +283,13 @@ function formatChannels(format, fallback) {
228
283
  return fallback;
229
284
  }
230
285
  function formatAudioSpecification(format) {
231
- const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
286
+ const sampleRate = formatSampleRate(format);
232
287
  const channels = formatChannels(format, 0);
233
288
  const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
234
289
  const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1e3 : void 0;
235
- 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";
290
+ 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";
291
+ const bitDepthMatch = /(\d+)bit/i.exec(format);
292
+ 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;
236
293
  return {
237
294
  format,
238
295
  mimeType: resolveMimeType(format),
@@ -240,7 +297,10 @@ function formatAudioSpecification(format) {
240
297
  sampleRate,
241
298
  channels,
242
299
  ...bitrate ? { bitrate } : {},
243
- isCompressed: codec === "mp3" || codec === "opus" || codec === "silk"
300
+ ...bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {},
301
+ ...container ? { container } : {},
302
+ isVbr: /vbr/i.test(format),
303
+ isCompressed: codec !== "pcm" && codec !== "unknown"
244
304
  };
245
305
  }
246
306
  function parseMp3Specification(buffer, format) {
@@ -275,6 +335,8 @@ function parseMp3Specification(buffer, format) {
275
335
  sampleRate,
276
336
  channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
277
337
  bitrate: bitrateKbps * 1e3,
338
+ container: "mp3-raw",
339
+ isVbr: false,
278
340
  isCompressed: true
279
341
  };
280
342
  }
@@ -289,24 +351,43 @@ function inspectAudioSpecification(buffer, format) {
289
351
  const channels = view.getUint16(2, true);
290
352
  const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
291
353
  const formatCode = view.getUint16(0, true);
354
+ const namedCodec = formatAudioSpecification(format).codec;
355
+ const codec = formatCode === 1 ? "pcm" : formatCode === 6 ? "alaw" : formatCode === 7 ? "mulaw" : namedCodec === "siren" ? "siren" : "unknown";
292
356
  return {
293
357
  format,
294
358
  mimeType: "audio/wav",
295
- codec: formatCode === 1 ? "pcm" : "unknown",
359
+ codec,
296
360
  sampleRate,
297
361
  channels,
298
362
  ...sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {},
299
- isCompressed: formatCode !== 1
363
+ bitDepth: bitsPerSample,
364
+ container: "riff-wave",
365
+ isVbr: false,
366
+ isCompressed: codec !== "pcm" && codec !== "unknown"
300
367
  };
301
368
  }
302
369
  if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
303
- return formatAudioSpecification(format);
370
+ const specification = formatAudioSpecification(format);
371
+ if (specification.container === "raw") validateRawAudioBuffer(buffer, specification);
372
+ return specification;
373
+ }
374
+ function validateRawAudioBuffer(buffer, specification) {
375
+ if (specification.sampleRate <= 0 || specification.channels <= 0 || specification.bitDepth === void 0) {
376
+ throw new Error(`RAW audio format "${specification.format}" does not define a complete audio specification.`);
377
+ }
378
+ if (specification.codec === "siren" || specification.codec === "silk") return;
379
+ const bytesPerFrame = specification.channels * Math.ceil(specification.bitDepth / 8);
380
+ if (bytesPerFrame <= 0 || buffer.byteLength % bytesPerFrame !== 0) {
381
+ throw new Error(
382
+ `RAW audio buffer size ${buffer.byteLength} is not aligned to ${bytesPerFrame}-byte audio frames for "${specification.format}".`
383
+ );
384
+ }
304
385
  }
305
386
  function validateAudioSpecifications(specs) {
306
387
  const first = specs[0];
307
388
  if (!first) return;
308
389
  const mismatch = specs.find(
309
- (spec) => spec.sampleRate !== first.sampleRate || spec.channels !== first.channels || first.bitrate !== void 0 && spec.bitrate !== void 0 && spec.bitrate !== first.bitrate
390
+ (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
310
391
  );
311
392
  if (mismatch)
312
393
  throw new AudioFormatMismatchError(
@@ -389,6 +470,30 @@ function isWavFormat(format) {
389
470
  function isRawFormat(format) {
390
471
  return /^raw(?:-|$)/i.test(format);
391
472
  }
473
+ function validateMergedAudioBuffer(merged, format, buffers, inputSpecs, outputMimeType) {
474
+ if (!(merged instanceof ArrayBuffer) || merged.byteLength === 0) {
475
+ throw new MergeError("The custom audio merger returned an empty or invalid audio buffer.");
476
+ }
477
+ const specification = inspectAudioSpecification(merged, format);
478
+ if (!outputMimeType.trim()) throw new MergeError("The custom audio merger output MIME type cannot be empty.");
479
+ const firstInput = inputSpecs[0];
480
+ if (firstInput && (specification.sampleRate !== firstInput.sampleRate || specification.channels !== firstInput.channels || specification.codec !== firstInput.codec || firstInput.bitDepth !== void 0 && specification.bitDepth !== firstInput.bitDepth)) {
481
+ throw new AudioFormatMismatchError("The custom audio merger returned an incompatible audio stream.", [
482
+ ...inputSpecs,
483
+ specification
484
+ ]);
485
+ }
486
+ if (isRawFormat(format)) {
487
+ const expectedSize = buffers.reduce((total, input) => total + input.byteLength, 0);
488
+ if (merged.byteLength !== expectedSize) {
489
+ throw new MergeError(
490
+ `The custom audio merger returned ${merged.byteLength} bytes; ${expectedSize} were expected.`
491
+ );
492
+ }
493
+ validateRawAudioBuffer(merged, specification);
494
+ }
495
+ return specification;
496
+ }
392
497
  function resolveMergeAudioFormat(format) {
393
498
  if (isWavFormat(format)) return "wav";
394
499
  if (isMp3Format(format)) return "mp3";
@@ -441,7 +546,7 @@ function closeSpeechResources(speechConfig, synthesizer) {
441
546
  }
442
547
  }
443
548
  var ticksToMilliseconds = (ticks) => Math.max(0, ticks) / 1e4;
444
- async function synthesizeSsml(ssml, config) {
549
+ async function synthesizeSsmlOnce(ssml, config) {
445
550
  if (config.signal?.aborted) {
446
551
  throw new SynthesisCancelledError();
447
552
  }
@@ -509,9 +614,7 @@ async function synthesizeSsml(ssml, config) {
509
614
  };
510
615
  }
511
616
  if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) {
512
- const unmapped = { mappingStatus: "unmapped" };
513
- Object.defineProperty(unmapped, "mappingStatus", { value: "unmapped", enumerable: false });
514
- return unmapped;
617
+ return { mappingStatus: "unmapped" };
515
618
  }
516
619
  const value = text ?? "";
517
620
  let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? offsetHint : -1;
@@ -573,6 +676,13 @@ async function synthesizeSsml(ssml, config) {
573
676
  rejectWithError(err);
574
677
  return;
575
678
  }
679
+ let audioSpec;
680
+ try {
681
+ audioSpec = inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT);
682
+ } catch (error) {
683
+ rejectWithError(error);
684
+ return;
685
+ }
576
686
  settled = true;
577
687
  cleanup();
578
688
  closeResources();
@@ -593,8 +703,13 @@ async function synthesizeSsml(ssml, config) {
593
703
  ...config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
594
704
  ...requestId ? { requestId } : {}
595
705
  };
596
- if (event.mappingStatus === "unmapped")
706
+ if (event.mappingStatus === "unmapped") {
597
707
  Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
708
+ Object.defineProperty(mapped, "toJSON", {
709
+ value: () => ({ ...mapped, mappingStatus: "unmapped" }),
710
+ enumerable: false
711
+ });
712
+ }
598
713
  return mapped;
599
714
  };
600
715
  const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
@@ -603,8 +718,8 @@ async function synthesizeSsml(ssml, config) {
603
718
  resolve({
604
719
  audioData: result.audioData,
605
720
  durationMs,
606
- audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
607
- mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
721
+ audioSpec,
722
+ mimeType: config.outputMimeType ?? resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
608
723
  ...config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {},
609
724
  ...requestId ? { requestId } : {},
610
725
  ...sourceBoundaries.length > 0 ? { boundaries: sourceBoundaries, wordBoundary: sourceBoundaries, wordBoundaries: sourceBoundaries } : {},
@@ -617,10 +732,11 @@ async function synthesizeSsml(ssml, config) {
617
732
  abortHandler = () => rejectWithError(new SynthesisCancelledError());
618
733
  config.signal.addEventListener("abort", abortHandler, { once: true });
619
734
  }
620
- if (config.timeoutMs !== void 0 && config.timeoutMs > 0) {
735
+ const timeoutMs = config.timeouts?.perChunkMs ?? config.timeouts?.totalJobMs ?? config.timeoutMs;
736
+ if (timeoutMs !== void 0 && timeoutMs > 0) {
621
737
  timeout = setTimeout(
622
- () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeoutMs} ms.`)),
623
- config.timeoutMs
738
+ () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
739
+ timeoutMs
624
740
  );
625
741
  }
626
742
  synthesizer.speakSsmlAsync(ssml, cb, rejectWithError);
@@ -639,7 +755,9 @@ function isRetryableSynthesisError(error) {
639
755
  if (typeof status === "number") return status === 429 || status >= 500 && status < 600;
640
756
  return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
641
757
  }
642
- function retryDelay(options, retryAttempt) {
758
+ function retryDelay(options, retryAttempt, error) {
759
+ const retryAfterMs = getRetryAfterDelayMs(error);
760
+ if (retryAfterMs !== void 0) return retryAfterMs;
643
761
  const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
644
762
  return Math.floor(Math.random() * (base + 1));
645
763
  }
@@ -667,32 +785,102 @@ async function waitForRetry(delayMs, signal) {
667
785
  }
668
786
  });
669
787
  }
670
- async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
788
+ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry, deadlineAtMs) {
671
789
  const options = retryOptions ? {
672
790
  maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
673
791
  initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
674
- maxDelayMs: Math.max(0, retryOptions.maxDelayMs)
792
+ maxDelayMs: Math.max(0, retryOptions.maxDelayMs),
793
+ shouldRetry: retryOptions.shouldRetry
675
794
  } : void 0;
676
795
  let attempt = 0;
677
796
  while (true) {
678
797
  if (config.signal?.aborted) throw new SynthesisCancelledError();
679
798
  try {
680
- return await synthesizeSsml(ssml, config);
799
+ return await synthesizeSsmlOnce(ssml, config);
681
800
  } catch (error) {
682
- if (!options || attempt >= options.maxRetries || !isRetryableSynthesisError(error)) throw error;
801
+ if (!options || attempt >= options.maxRetries || !(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error)))
802
+ throw error;
683
803
  attempt += 1;
684
- const delayMs = retryDelay(options, attempt);
804
+ const delayMs = retryDelay(options, attempt, error);
805
+ const remainingMs = deadlineAtMs === void 0 ? void 0 : Math.max(0, deadlineAtMs - Date.now());
806
+ if (getRetryAfterDelayMs(error) !== void 0 && (delayMs > options.maxDelayMs || remainingMs !== void 0 && delayMs > remainingMs)) {
807
+ throw new SynthesisTimeoutError(
808
+ remainingMs === void 0 ? `Retry-After exceeded maxDelayMs (${options.maxDelayMs} ms).` : `Retry-After exceeded the remaining total job timeout (${remainingMs} ms).`
809
+ );
810
+ }
685
811
  onRetry(attempt, delayMs);
686
- await waitForRetry(delayMs, config.signal);
812
+ await waitForRetry(Math.min(delayMs, remainingMs ?? delayMs), config.signal);
687
813
  }
688
814
  }
689
815
  }
816
+ async function synthesizeSsml(ssml, config) {
817
+ const totalJobMs = config.timeouts?.totalJobMs;
818
+ const deadlineAtMs = totalJobMs !== void 0 && totalJobMs > 0 ? Date.now() + totalJobMs : void 0;
819
+ if (!config.retryOptions) return synthesizeSsmlOnce(ssml, config);
820
+ return synthesizeWithRetry(ssml, config, config.retryOptions, () => void 0, deadlineAtMs);
821
+ }
822
+ function createAbortScope(parent, timeoutMs) {
823
+ const controller = new AbortController();
824
+ let didTimeout = false;
825
+ const onAbort = () => controller.abort();
826
+ if (parent?.aborted) controller.abort();
827
+ parent?.addEventListener("abort", onAbort, { once: true });
828
+ const timer = timeoutMs !== void 0 && timeoutMs > 0 ? setTimeout(() => {
829
+ didTimeout = true;
830
+ controller.abort();
831
+ }, timeoutMs) : void 0;
832
+ return {
833
+ signal: controller.signal,
834
+ timedOut: () => didTimeout,
835
+ dispose: () => {
836
+ if (timer) clearTimeout(timer);
837
+ parent?.removeEventListener("abort", onAbort);
838
+ },
839
+ abort: () => controller.abort()
840
+ };
841
+ }
842
+ async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs, onRetry, deadlineAtMs) {
843
+ const scope = createAbortScope(config.signal, timeoutMs);
844
+ try {
845
+ return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry, deadlineAtMs);
846
+ } catch (error) {
847
+ if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
848
+ throw error;
849
+ } finally {
850
+ scope.dispose();
851
+ }
852
+ }
690
853
  async function synthesizeSsmlChunks(chunks, config) {
691
- const results = new Array(chunks.length);
692
854
  const totalChunks = chunks.length;
855
+ const inputs = chunks.map((chunk) => typeof chunk === "string" ? { ssml: chunk } : chunk);
856
+ const fingerprints = inputs.map(
857
+ (chunk) => computeChunkFingerprint(chunk.ssml, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT)
858
+ );
859
+ const results = new Array(totalChunks);
860
+ const cachedChunks = new Map((config.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
861
+ const invalidCachedIndices = /* @__PURE__ */ new Set();
862
+ const chunkStates = inputs.map((_chunk, chunkIndex) => ({
863
+ chunkIndex,
864
+ status: "pending",
865
+ canResume: true
866
+ }));
867
+ for (const [index, cached] of cachedChunks) {
868
+ if (index < 0 || index >= totalChunks) continue;
869
+ const isValid = config.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index];
870
+ if (isValid) {
871
+ results[index] = { ...cached };
872
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
873
+ } else {
874
+ invalidCachedIndices.add(index);
875
+ }
876
+ }
877
+ const requestedIndices = config.resumeChunkIndices ? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks)) : void 0;
878
+ const shouldSynthesize = (index) => (!cachedChunks.has(index) || invalidCachedIndices.has(index)) && (requestedIndices === void 0 || requestedIndices.has(index) || invalidCachedIndices.has(index));
879
+ const jobStartedAt = Date.now();
880
+ const jobDeadlineAt = config.timeouts?.totalJobMs !== void 0 && config.timeouts.totalJobMs > 0 ? jobStartedAt + config.timeouts.totalJobMs : void 0;
881
+ const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
693
882
  const report = (event) => config.onProgress?.(event);
694
- for (const [index, chunk] of chunks.entries()) {
695
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
883
+ for (const [index, input] of inputs.entries()) {
696
884
  report({
697
885
  currentChunk: index,
698
886
  totalChunks,
@@ -703,15 +891,18 @@ async function synthesizeSsmlChunks(chunks, config) {
703
891
  durationMs: 0
704
892
  });
705
893
  }
706
- let completed = 0;
894
+ let completed = [...results].filter((result) => result !== void 0).length;
707
895
  let nextIndex = 0;
708
896
  const concurrency = resolveConcurrency(config.concurrency, chunks.length);
897
+ let firstError;
898
+ const failedIndices = /* @__PURE__ */ new Set();
709
899
  const worker = async () => {
710
900
  while (true) {
711
901
  const index = nextIndex++;
712
902
  if (index >= chunks.length) return;
713
- const chunk = chunks[index];
714
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
903
+ if (!shouldSynthesize(index)) continue;
904
+ if (firstError && config.cancelOnFailure !== false) return;
905
+ const input = inputs[index];
715
906
  report({
716
907
  currentChunk: completed,
717
908
  totalChunks,
@@ -723,10 +914,11 @@ async function synthesizeSsmlChunks(chunks, config) {
723
914
  });
724
915
  const startedAt = Date.now();
725
916
  try {
726
- const result = await synthesizeWithRetry(
917
+ const result = await synthesizeChunkWithTimeout(
727
918
  input.ssml,
728
919
  {
729
920
  ...config,
921
+ signal: scope.signal,
730
922
  ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
731
923
  ...input.sourceNodePath ?? config.sourceNodePath ? { sourceNodePath: [...input.sourceNodePath ?? config.sourceNodePath ?? []] } : {},
732
924
  ...input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {},
@@ -735,6 +927,7 @@ async function synthesizeSsmlChunks(chunks, config) {
735
927
  onProgress: void 0
736
928
  },
737
929
  config.retryOptions,
930
+ config.timeouts?.chunkWithRetriesMs ?? config.timeouts?.perChunkMs ?? config.timeoutMs,
738
931
  (retryAttempt, nextRetryDelayMs) => report({
739
932
  currentChunk: completed,
740
933
  totalChunks,
@@ -746,9 +939,11 @@ async function synthesizeSsmlChunks(chunks, config) {
746
939
  retryAttempt,
747
940
  nextRetryDelayMs,
748
941
  isRetrying: true
749
- })
942
+ }),
943
+ jobDeadlineAt
750
944
  );
751
945
  results[index] = result;
946
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result };
752
947
  completed += 1;
753
948
  report({
754
949
  currentChunk: completed,
@@ -760,6 +955,16 @@ async function synthesizeSsmlChunks(chunks, config) {
760
955
  durationMs: Date.now() - startedAt
761
956
  });
762
957
  } catch (error) {
958
+ const wasCancelled = firstError !== void 0 || scope.signal.aborted && !scope.timedOut();
959
+ firstError ?? (firstError = scope.timedOut() ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`) : error);
960
+ if (!wasCancelled) failedIndices.add(index);
961
+ chunkStates[index] = {
962
+ chunkIndex: index,
963
+ status: wasCancelled ? "cancelled" : "failed",
964
+ isOriginalFailure: !wasCancelled,
965
+ canResume: true,
966
+ error
967
+ };
763
968
  report({
764
969
  currentChunk: completed,
765
970
  totalChunks,
@@ -770,16 +975,49 @@ async function synthesizeSsmlChunks(chunks, config) {
770
975
  durationMs: Date.now() - startedAt,
771
976
  error
772
977
  });
773
- throw error;
978
+ if (config.cancelOnFailure !== false) scope.abort();
979
+ return;
774
980
  }
775
981
  }
776
982
  };
777
- await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
778
- const orderedResults = results.filter((result) => result !== void 0);
779
- return mergeSynthesisResults(orderedResults, {
780
- format: config.outputFormat ?? DEFAULT_OUTPUT_FORMAT,
781
- signal: config.signal
782
- });
983
+ try {
984
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
985
+ if (firstError) throw firstError;
986
+ const orderedResults = results.filter((result) => result !== void 0);
987
+ return await mergeSynthesisResults(orderedResults, {
988
+ format: config.outputFormat ?? DEFAULT_OUTPUT_FORMAT,
989
+ signal: scope.signal,
990
+ customMerger: config.customMerger,
991
+ outputMimeType: config.outputMimeType,
992
+ postMergeValidator: config.postMergeValidator
993
+ });
994
+ } catch (error) {
995
+ if (firstError && config.cancelOnFailure !== false) {
996
+ for (const [chunkIndex, state] of chunkStates.entries()) {
997
+ if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
998
+ chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
999
+ }
1000
+ }
1001
+ }
1002
+ const synthesizedChunks = results.flatMap(
1003
+ (result, chunkIndex) => result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : []
1004
+ );
1005
+ const partial = {
1006
+ synthesizedChunks,
1007
+ completedChunks: synthesizedChunks,
1008
+ pendingChunkIndices: chunkStates.flatMap(
1009
+ (state) => state.status === "pending" || state.status === "cancelled" || state.status === "failed" ? [state.chunkIndex] : []
1010
+ ),
1011
+ failedChunkIndices: [...failedIndices],
1012
+ cancelledChunkIndices: chunkStates.filter((state) => state.status === "cancelled").map((state) => state.chunkIndex),
1013
+ chunkStates,
1014
+ totalChunks
1015
+ };
1016
+ if (error && typeof error === "object") error.partialResult = partial;
1017
+ throw error;
1018
+ } finally {
1019
+ scope.dispose();
1020
+ }
783
1021
  }
784
1022
  function createMergedResult(results, audioData, format, audioSpec, outputMimeType) {
785
1023
  const boundaries = [];
@@ -870,16 +1108,26 @@ function mergeSynthesisResults(results, options) {
870
1108
  })
871
1109
  ).then((merged) => {
872
1110
  if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
873
- if (!(merged instanceof ArrayBuffer) || buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
874
- throw new MergeError("The custom audio merger returned an invalid audio buffer.");
875
1111
  if (signal.aborted) throw new SynthesisCancelledError();
876
- return createMergedResult(
877
- results,
1112
+ const mergedSpec = validateMergedAudioBuffer(
878
1113
  merged,
879
1114
  format,
880
- inspectAudioSpecification(merged, format),
881
- resolvedOptions.outputMimeType
1115
+ buffers,
1116
+ inputSpecs,
1117
+ resolvedOptions.outputMimeType ?? resolveMimeType(format)
882
1118
  );
1119
+ const result = createMergedResult(results, merged, format, mergedSpec, resolvedOptions.outputMimeType);
1120
+ return Promise.resolve(
1121
+ resolvedOptions.postMergeValidator?.(result, {
1122
+ format,
1123
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
1124
+ inputSpecs,
1125
+ signal
1126
+ })
1127
+ ).then((valid) => {
1128
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1129
+ return result;
1130
+ });
883
1131
  }).catch((error) => {
884
1132
  if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
885
1133
  throw error;
@@ -887,13 +1135,28 @@ function mergeSynthesisResults(results, options) {
887
1135
  });
888
1136
  }
889
1137
  try {
890
- return createMergedResult(
1138
+ const result = createMergedResult(
891
1139
  results,
892
1140
  mergeAudioBuffers(buffers, { format }),
893
1141
  format,
894
1142
  inputSpecs[0],
895
1143
  resolvedOptions.outputMimeType
896
1144
  );
1145
+ if (resolvedOptions.postMergeValidator) {
1146
+ const validation = resolvedOptions.postMergeValidator(result, {
1147
+ format,
1148
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
1149
+ inputSpecs,
1150
+ signal
1151
+ });
1152
+ if (validation instanceof Promise)
1153
+ return validation.then((valid) => {
1154
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1155
+ return result;
1156
+ });
1157
+ if (validation === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1158
+ }
1159
+ return result;
897
1160
  } catch (error) {
898
1161
  if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
899
1162
  throw error;
@@ -914,8 +1177,52 @@ var ChunkValidationError = class extends Error {
914
1177
  this.diagnostics = diagnostics;
915
1178
  }
916
1179
  };
917
- function failure(error) {
918
- return { ok: false, success: false, status: error.kind, error };
1180
+ var BatchChunkValidationError = class extends ChunkValidationError {
1181
+ constructor(chunkDiagnostics) {
1182
+ const first = chunkDiagnostics[0];
1183
+ super(first?.chunkIndex ?? -1, first?.diagnostics ?? []);
1184
+ this.name = "BatchChunkValidationError";
1185
+ this.message = `SSML validation failed for ${chunkDiagnostics.length} chunk(s); the Azure Speech API was not called.`;
1186
+ this.chunkDiagnostics = chunkDiagnostics;
1187
+ this.totalErrorCount = chunkDiagnostics.reduce((total, chunk) => total + chunk.diagnostics.length, 0);
1188
+ this.errorCount = this.totalErrorCount;
1189
+ this.totalErrors = this.totalErrorCount;
1190
+ }
1191
+ };
1192
+ function failure(error, partialResult) {
1193
+ return {
1194
+ ok: false,
1195
+ success: false,
1196
+ status: error.kind,
1197
+ error,
1198
+ ...partialResult ? { partialResult } : {}
1199
+ };
1200
+ }
1201
+ function partialResultFrom(error) {
1202
+ if (!error || typeof error !== "object") return void 0;
1203
+ const partial = error.partialResult;
1204
+ if (!partial || typeof partial !== "object") return void 0;
1205
+ return partial;
1206
+ }
1207
+ function createSafeAbortScope(parent, timeoutMs) {
1208
+ const controller = new AbortController();
1209
+ let didTimeout = false;
1210
+ const onAbort = () => controller.abort();
1211
+ if (parent?.aborted) controller.abort();
1212
+ parent?.addEventListener("abort", onAbort, { once: true });
1213
+ const timer = timeoutMs !== void 0 && timeoutMs > 0 ? setTimeout(() => {
1214
+ didTimeout = true;
1215
+ controller.abort();
1216
+ }, timeoutMs) : void 0;
1217
+ return {
1218
+ signal: controller.signal,
1219
+ timedOut: () => didTimeout,
1220
+ dispose: () => {
1221
+ if (timer) clearTimeout(timer);
1222
+ parent?.removeEventListener("abort", onAbort);
1223
+ },
1224
+ abort: () => controller.abort()
1225
+ };
919
1226
  }
920
1227
  function isRetryable(error) {
921
1228
  if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
@@ -930,16 +1237,20 @@ function delayForRetry(options, attempt) {
930
1237
  const base = Math.min(maxDelay, Math.max(0, options.initialDelayMs) * 2 ** Math.max(0, attempt - 1));
931
1238
  return Math.floor(Math.random() * (base + 1));
932
1239
  }
1240
+ function retryDelayForError(options, attempt, error) {
1241
+ return getRetryAfterDelayMs(error) ?? delayForRetry(options, attempt);
1242
+ }
933
1243
  function resolveConcurrency2(value, total) {
934
1244
  if (value === void 0) return 1;
935
1245
  if (value === Infinity) return Math.max(1, total);
936
1246
  return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
937
1247
  }
938
- async function retryableSynthesis(synthesize, options, signal, onRetry) {
1248
+ async function retryableSynthesis(synthesize, options, signal, onRetry, deadlineAtMs) {
939
1249
  const retry = options ? {
940
1250
  maxRetries: Math.max(0, Math.floor(options.maxRetries)),
941
1251
  initialDelayMs: options.initialDelayMs,
942
- maxDelayMs: options.maxDelayMs
1252
+ maxDelayMs: options.maxDelayMs,
1253
+ shouldRetry: options.shouldRetry
943
1254
  } : void 0;
944
1255
  let attempt = 0;
945
1256
  while (true) {
@@ -947,9 +1258,15 @@ async function retryableSynthesis(synthesize, options, signal, onRetry) {
947
1258
  try {
948
1259
  return await synthesize();
949
1260
  } catch (error) {
950
- if (!retry || attempt >= retry.maxRetries || !isRetryable(error)) throw error;
1261
+ if (!retry || attempt >= retry.maxRetries || !(retry.shouldRetry?.(error, attempt + 1) ?? isRetryable(error)))
1262
+ throw error;
951
1263
  attempt += 1;
952
- const delayMs = delayForRetry(retry, attempt);
1264
+ const delayMs = retryDelayForError(retry, attempt, error);
1265
+ const retryAfterMs = getRetryAfterDelayMs(error);
1266
+ const remainingMs = deadlineAtMs === void 0 ? void 0 : Math.max(0, deadlineAtMs - Date.now());
1267
+ if (retryAfterMs !== void 0 && (retryAfterMs > retry.maxDelayMs || remainingMs !== void 0 && retryAfterMs > remainingMs)) {
1268
+ throw new Error("Speech synthesis timed out because Retry-After exceeded the available retry budget.");
1269
+ }
953
1270
  onRetry(attempt, delayMs);
954
1271
  if (delayMs > 0)
955
1272
  await new Promise((resolve, reject) => {
@@ -973,7 +1290,7 @@ function sharedValidationOptions(options, signal) {
973
1290
  const runner = createAzureUrlValidatorRunner2(validator, {
974
1291
  ...options.urlValidation ?? {},
975
1292
  ...options.urlValidatorConcurrency !== void 0 ? { concurrency: options.urlValidatorConcurrency } : {},
976
- ...options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
1293
+ ...options.timeouts?.urlValidationMs !== void 0 ? { timeoutMs: options.timeouts.urlValidationMs } : options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
977
1294
  ...signal ? { signal } : {},
978
1295
  ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
979
1296
  });
@@ -997,20 +1314,31 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
997
1314
  diagnostics: errors
998
1315
  });
999
1316
  }
1317
+ const jobScope = options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts.totalJobMs) : void 0;
1000
1318
  try {
1001
1319
  return {
1002
1320
  ok: true,
1003
1321
  success: true,
1004
1322
  status: "success",
1005
- value: await client.synthesizeSsml(ssml, { signal: options.signal })
1323
+ value: await client.synthesizeSsml(ssml, {
1324
+ signal: jobScope?.signal ?? options.signal,
1325
+ timeoutMs: options.timeouts?.perChunkMs,
1326
+ timeouts: options.timeouts
1327
+ })
1006
1328
  };
1007
1329
  } catch (error) {
1330
+ if (jobScope?.timedOut()) return failure(toSynthesisError(new Error("Speech synthesis timed out.")));
1008
1331
  const synthesisError = toSynthesisError(error);
1009
1332
  return failure(synthesisError);
1333
+ } finally {
1334
+ jobScope?.dispose();
1010
1335
  }
1011
1336
  }
1012
1337
  async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1013
- const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
1338
+ const validationOptions = sharedValidationOptions(
1339
+ { ...options.validation ?? options, timeouts: options.timeouts },
1340
+ options.signal
1341
+ );
1014
1342
  if (options.signal?.aborted) {
1015
1343
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
1016
1344
  return failure(error);
@@ -1044,16 +1372,17 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1044
1372
  return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
1045
1373
  })
1046
1374
  );
1047
- const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
1048
1375
  if (options.signal?.aborted) {
1049
1376
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
1050
1377
  return failure(error);
1051
1378
  }
1052
- if (firstInvalidIndex >= 0) {
1053
- const error = new ChunkValidationError(firstInvalidIndex, validations[firstInvalidIndex] ?? []);
1054
- pending(firstInvalidIndex, "failed", error);
1379
+ const chunkDiagnostics = validations.map((diagnostics, chunkIndex) => ({ chunkIndex, diagnostics })).filter((entry) => entry.diagnostics.length > 0);
1380
+ if (chunkDiagnostics.length > 0) {
1381
+ const error = new BatchChunkValidationError(chunkDiagnostics);
1382
+ for (const entry of chunkDiagnostics) pending(entry.chunkIndex, "failed", error);
1055
1383
  return failure(error);
1056
1384
  }
1385
+ let fallbackJobScope;
1057
1386
  try {
1058
1387
  if (client.synthesizeChunks) {
1059
1388
  const normalizedChunks = chunks.map((chunk) => {
@@ -1065,20 +1394,57 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1065
1394
  outputFormat: options.outputFormat,
1066
1395
  signal: options.signal,
1067
1396
  timeoutMs: options.timeoutMs,
1397
+ timeouts: options.timeouts,
1068
1398
  sourceNodePath: options.sourceNodePath,
1069
1399
  concurrency: options.concurrency,
1070
- retryOptions: options.retryOptions
1400
+ retryOptions: options.retryOptions,
1401
+ cancelOnFailure: options.cancelOnFailure,
1402
+ resumeChunks: options.resumeChunks,
1403
+ resumeChunkIndices: options.resumeChunkIndices,
1404
+ customMerger: options.customMerger,
1405
+ outputMimeType: options.outputMimeType,
1406
+ postMergeValidator: options.postMergeValidator,
1407
+ resumeValidation: options.resumeValidation
1071
1408
  });
1072
1409
  return { ok: true, success: true, status: "success", value };
1073
1410
  }
1411
+ const inputs = chunks.map((chunk) => typeof chunk === "string" ? { ssml: chunk } : chunk);
1412
+ const fingerprints = inputs.map((chunk) => computeChunkFingerprint(chunk.ssml, options.outputFormat));
1074
1413
  const results = new Array(chunks.length);
1075
- let completed = 0;
1414
+ const chunkStates = inputs.map((_chunk, chunkIndex) => ({
1415
+ chunkIndex,
1416
+ status: "pending",
1417
+ canResume: true
1418
+ }));
1419
+ const cachedChunks = new Map((options.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
1420
+ const invalidCachedIndices = /* @__PURE__ */ new Set();
1421
+ for (const [index, cached] of cachedChunks) {
1422
+ if (index < 0 || index >= chunks.length) continue;
1423
+ if (options.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index]) {
1424
+ results[index] = cached;
1425
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: cached };
1426
+ } else invalidCachedIndices.add(index);
1427
+ }
1428
+ const requestedIndices = options.resumeChunkIndices ? new Set(options.resumeChunkIndices.filter((index) => index >= 0 && index < chunks.length)) : void 0;
1429
+ const shouldSynthesize = (index) => (!cachedChunks.has(index) || invalidCachedIndices.has(index)) && (requestedIndices === void 0 || requestedIndices.has(index) || invalidCachedIndices.has(index));
1430
+ const jobStartedAt = Date.now();
1431
+ const jobDeadlineAt = options.timeouts?.totalJobMs !== void 0 && options.timeouts.totalJobMs > 0 ? jobStartedAt + options.timeouts.totalJobMs : void 0;
1432
+ const jobScope = chunks.length > 1 || options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs) : void 0;
1433
+ fallbackJobScope = jobScope;
1434
+ const failedIndices = /* @__PURE__ */ new Set();
1435
+ let firstError;
1436
+ let completed = [...results].filter((result) => result !== void 0).length;
1076
1437
  let nextIndex = 0;
1077
1438
  const concurrency = resolveConcurrency2(options.concurrency, chunks.length);
1078
1439
  const worker = async () => {
1079
1440
  while (true) {
1080
1441
  const index = nextIndex++;
1081
1442
  if (index >= chunks.length) return;
1443
+ if (!shouldSynthesize(index)) continue;
1444
+ if (firstError && options.cancelOnFailure !== false) {
1445
+ chunkStates[index] = { chunkIndex: index, status: "cancelled", isOriginalFailure: false, canResume: true };
1446
+ return;
1447
+ }
1082
1448
  const chunk = chunks[index];
1083
1449
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
1084
1450
  const sourceNodePath = input.sourceNodePath;
@@ -1086,28 +1452,41 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1086
1452
  pending(index, "synthesizing");
1087
1453
  const startedAt = Date.now();
1088
1454
  try {
1089
- const result = await retryableSynthesis(
1090
- () => client.synthesizeSsml(input.ssml, {
1091
- outputFormat: options.outputFormat,
1092
- signal: options.signal,
1093
- timeoutMs: options.timeoutMs,
1094
- sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
1095
- }),
1096
- options.retryOptions,
1097
- options.signal,
1098
- (retryAttempt, nextRetryDelayMs) => options.onProgress?.({
1099
- currentChunk: completed,
1100
- totalChunks: chunks.length,
1101
- percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1102
- chunkIndex: index,
1103
- originalTextRange: input.originalTextRange,
1104
- status: "synthesizing",
1105
- durationMs: Date.now() - startedAt,
1106
- retryAttempt,
1107
- nextRetryDelayMs,
1108
- isRetrying: true
1109
- })
1110
- );
1455
+ const chunkTimeout = options.timeouts?.chunkWithRetriesMs ?? options.timeouts?.perChunkMs;
1456
+ const chunkScope = chunkTimeout !== void 0 || jobScope ? createSafeAbortScope(jobScope?.signal ?? options.signal, chunkTimeout ?? options.timeoutMs) : void 0;
1457
+ const chunkSignal = chunkScope?.signal ?? options.signal;
1458
+ let result;
1459
+ try {
1460
+ result = await retryableSynthesis(
1461
+ () => client.synthesizeSsml(input.ssml, {
1462
+ outputFormat: options.outputFormat,
1463
+ signal: chunkSignal,
1464
+ timeoutMs: options.timeouts?.perChunkMs ?? options.timeoutMs,
1465
+ sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
1466
+ }),
1467
+ options.retryOptions,
1468
+ chunkSignal,
1469
+ (retryAttempt, nextRetryDelayMs) => options.onProgress?.({
1470
+ currentChunk: completed,
1471
+ totalChunks: chunks.length,
1472
+ percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1473
+ chunkIndex: index,
1474
+ originalTextRange: input.originalTextRange,
1475
+ status: "synthesizing",
1476
+ durationMs: Date.now() - startedAt,
1477
+ retryAttempt,
1478
+ nextRetryDelayMs,
1479
+ isRetrying: true
1480
+ }),
1481
+ jobDeadlineAt
1482
+ );
1483
+ } catch (error) {
1484
+ if (chunkScope?.timedOut())
1485
+ throw new Error(`Speech synthesis timed out after ${chunkTimeout ?? options.timeoutMs} ms.`);
1486
+ throw error;
1487
+ } finally {
1488
+ chunkScope?.dispose();
1489
+ }
1111
1490
  results[index] = {
1112
1491
  ...result,
1113
1492
  ...input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {},
@@ -1151,6 +1530,7 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1151
1530
  }))
1152
1531
  } : {}
1153
1532
  };
1533
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
1154
1534
  completed += 1;
1155
1535
  options.onProgress?.({
1156
1536
  currentChunk: completed,
@@ -1162,6 +1542,16 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1162
1542
  durationMs: Date.now() - startedAt
1163
1543
  });
1164
1544
  } catch (error) {
1545
+ const wasCancelled = firstError !== void 0 || Boolean(jobScope?.signal.aborted && !jobScope?.timedOut());
1546
+ firstError ?? (firstError = error);
1547
+ if (!wasCancelled) failedIndices.add(index);
1548
+ chunkStates[index] = {
1549
+ chunkIndex: index,
1550
+ status: wasCancelled ? "cancelled" : "failed",
1551
+ isOriginalFailure: !wasCancelled,
1552
+ canResume: true,
1553
+ error
1554
+ };
1165
1555
  options.onProgress?.({
1166
1556
  currentChunk: completed,
1167
1557
  totalChunks: chunks.length,
@@ -1172,24 +1562,55 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1172
1562
  durationMs: Date.now() - startedAt,
1173
1563
  error
1174
1564
  });
1175
- throw error;
1565
+ if (options.cancelOnFailure !== false) jobScope?.abort();
1566
+ return;
1176
1567
  }
1177
1568
  }
1178
1569
  };
1179
1570
  await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
1571
+ if (firstError && options.cancelOnFailure !== false) {
1572
+ for (const [chunkIndex, state] of chunkStates.entries()) {
1573
+ if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
1574
+ chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
1575
+ }
1576
+ }
1577
+ }
1578
+ if (failedIndices.size > 0) {
1579
+ const error = firstError ?? new Error("One or more SSML chunks failed to synthesize.");
1580
+ const synthesizedChunks = results.flatMap(
1581
+ (result, chunkIndex) => result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : []
1582
+ );
1583
+ error.partialResult = {
1584
+ synthesizedChunks,
1585
+ completedChunks: synthesizedChunks,
1586
+ pendingChunkIndices: chunkStates.flatMap(
1587
+ (state) => state.status === "pending" || state.status === "cancelled" || state.status === "failed" ? [state.chunkIndex] : []
1588
+ ),
1589
+ failedChunkIndices: [...failedIndices],
1590
+ cancelledChunkIndices: chunkStates.filter((state) => state.status === "cancelled").map((state) => state.chunkIndex),
1591
+ chunkStates,
1592
+ totalChunks: chunks.length
1593
+ };
1594
+ throw error;
1595
+ }
1180
1596
  const orderedResults = results.filter((result) => result !== void 0);
1181
1597
  return {
1182
1598
  ok: true,
1183
1599
  success: true,
1184
1600
  status: "success",
1185
- value: mergeSynthesisResults(orderedResults, {
1601
+ value: await mergeSynthesisResults(orderedResults, {
1186
1602
  format: options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3",
1187
- signal: options.signal
1603
+ signal: jobScope?.signal ?? options.signal,
1604
+ customMerger: options.customMerger,
1605
+ outputMimeType: options.outputMimeType,
1606
+ postMergeValidator: options.postMergeValidator
1188
1607
  })
1189
1608
  };
1190
1609
  } catch (error) {
1191
1610
  const synthesisError = toSynthesisError(error);
1192
- return failure(synthesisError);
1611
+ return failure(synthesisError, partialResultFrom(error));
1612
+ } finally {
1613
+ fallbackJobScope?.dispose();
1193
1614
  }
1194
1615
  }
1195
1616
  function withValidationSignal(options, signal) {
@@ -1210,14 +1631,23 @@ var AzureTtsClient = class {
1210
1631
  __privateSet(this, _options, options);
1211
1632
  }
1212
1633
  async synthesize(ssml) {
1213
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1634
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
1214
1635
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
1215
1636
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
1216
- const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs };
1637
+ const config = {
1638
+ endpoint,
1639
+ region,
1640
+ subscriptionKey,
1641
+ outputFormat,
1642
+ signal,
1643
+ timeoutMs,
1644
+ timeouts,
1645
+ retryOptions: __privateGet(this, _options).retryOptions
1646
+ };
1217
1647
  return synthesizeSpeech(ssml, config);
1218
1648
  }
1219
1649
  async synthesizeSsml(ssml, options = {}) {
1220
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1650
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
1221
1651
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
1222
1652
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
1223
1653
  return synthesizeSsml(ssml, {
@@ -1227,13 +1657,20 @@ var AzureTtsClient = class {
1227
1657
  outputFormat: options.outputFormat ?? outputFormat,
1228
1658
  signal: options.signal ?? signal,
1229
1659
  timeoutMs: options.timeoutMs ?? timeoutMs,
1660
+ timeouts: options.timeouts ?? timeouts,
1230
1661
  sourceNodePath: options.sourceNodePath,
1231
1662
  sourceTextSegments: options.sourceTextSegments,
1232
- sourceMarkers: options.sourceMarkers
1663
+ sourceMarkers: options.sourceMarkers,
1664
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
1665
+ cancelOnFailure: options.cancelOnFailure ?? __privateGet(this, _options).cancelOnFailure,
1666
+ customMerger: options.customMerger ?? __privateGet(this, _options).customMerger,
1667
+ outputMimeType: options.outputMimeType ?? __privateGet(this, _options).outputMimeType,
1668
+ postMergeValidator: options.postMergeValidator ?? __privateGet(this, _options).postMergeValidator,
1669
+ resumeValidation: options.resumeValidation ?? __privateGet(this, _options).resumeValidation
1233
1670
  });
1234
1671
  }
1235
1672
  async synthesizeChunks(chunks, options = {}) {
1236
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1673
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
1237
1674
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
1238
1675
  return synthesizeSsmlChunks(chunks, {
1239
1676
  endpoint,
@@ -1242,10 +1679,18 @@ var AzureTtsClient = class {
1242
1679
  outputFormat: options.outputFormat ?? outputFormat,
1243
1680
  signal: options.signal ?? signal,
1244
1681
  timeoutMs: options.timeoutMs ?? timeoutMs,
1682
+ timeouts: options.timeouts ?? timeouts,
1245
1683
  sourceNodePath: options.sourceNodePath,
1246
1684
  onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1247
1685
  concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1248
- retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
1686
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
1687
+ cancelOnFailure: options.cancelOnFailure ?? __privateGet(this, _options).cancelOnFailure,
1688
+ resumeChunks: options.resumeChunks,
1689
+ resumeChunkIndices: options.resumeChunkIndices,
1690
+ customMerger: options.customMerger ?? __privateGet(this, _options).customMerger,
1691
+ outputMimeType: options.outputMimeType ?? __privateGet(this, _options).outputMimeType,
1692
+ postMergeValidator: options.postMergeValidator ?? __privateGet(this, _options).postMergeValidator,
1693
+ resumeValidation: options.resumeValidation ?? __privateGet(this, _options).resumeValidation
1249
1694
  });
1250
1695
  }
1251
1696
  async synthesizeSsmlSafe(ssml, options = {}) {
@@ -1257,6 +1702,7 @@ var AzureTtsClient = class {
1257
1702
  outputFormat: options.outputFormat ?? __privateGet(this, _options).outputFormat,
1258
1703
  signal: options.signal ?? __privateGet(this, _options).signal,
1259
1704
  timeoutMs: options.timeoutMs ?? __privateGet(this, _options).timeoutMs,
1705
+ timeouts: options.timeouts ?? __privateGet(this, _options).timeouts,
1260
1706
  onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1261
1707
  concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1262
1708
  retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
@@ -1350,7 +1796,9 @@ async function fetchAzureVoiceCatalog(options) {
1350
1796
  voiceCount: sortedVoices.length,
1351
1797
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1352
1798
  apiVersion: AZURE_VOICE_API_VERSION,
1353
- regions
1799
+ regions,
1800
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1e3).toISOString(),
1801
+ regionDiffs: {}
1354
1802
  }
1355
1803
  };
1356
1804
  }
@@ -1359,6 +1807,7 @@ export {
1359
1807
  AzureTtsClient,
1360
1808
  AzureTtsError,
1361
1809
  AzureTtsSdkError,
1810
+ BatchChunkValidationError,
1362
1811
  ChunkValidationError,
1363
1812
  DEFAULT_OUTPUT_FORMAT,
1364
1813
  MergeError,
@@ -1369,6 +1818,7 @@ export {
1369
1818
  buildPartialSsml,
1370
1819
  buildSsml,
1371
1820
  canMergeAudioFormat,
1821
+ computeChunkFingerprint,
1372
1822
  createAzureUrlValidatorRunner,
1373
1823
  extractSsmlText,
1374
1824
  extractSsmlTranslatableText,
@@ -1376,6 +1826,7 @@ export {
1376
1826
  fromPlainTextToSsml,
1377
1827
  getAzureVoiceCatalogMetadata,
1378
1828
  getBuiltInVoiceCatalogMetadata,
1829
+ getRetryAfterDelayMs,
1379
1830
  getSsmlSourceMap,
1380
1831
  inspectAudioSpecification,
1381
1832
  isValidAzureAudioDuration,