@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.mjs CHANGED
@@ -8,7 +8,7 @@ var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "
8
8
 
9
9
  // src/errors.ts
10
10
  var AzureTtsError = class extends Error {
11
- constructor(status, statusText, responseBody, requestId) {
11
+ constructor(status, statusText, responseBody, requestId, responseHeaders) {
12
12
  super(`Azure TTS request failed: ${status} ${statusText}`);
13
13
  this.kind = "azure-api-error";
14
14
  this.name = "AzureTtsError";
@@ -16,8 +16,37 @@ var AzureTtsError = class extends Error {
16
16
  this.statusText = statusText;
17
17
  this.responseBody = responseBody;
18
18
  this.requestId = requestId;
19
+ const value = responseHeaders instanceof Headers ? responseHeaders.get("retry-after") : responseHeaders?.["retry-after"] ?? responseHeaders?.["Retry-After"];
20
+ const seconds = value ? Number(value.trim()) : NaN;
21
+ const date = value ? Date.parse(value) : NaN;
22
+ if (Number.isFinite(seconds) && seconds >= 0) this.retryAfterMs = seconds * 1e3;
23
+ else if (Number.isFinite(date)) this.retryAfterMs = Math.max(0, date - Date.now());
19
24
  }
20
25
  };
26
+ function getRetryAfterDelayMs(error) {
27
+ if (error instanceof AzureTtsError && error.retryAfterMs !== void 0) return error.retryAfterMs;
28
+ if (!error || typeof error !== "object") return void 0;
29
+ const candidate = error;
30
+ if (typeof candidate.retryAfterMs === "number" && candidate.retryAfterMs >= 0) return candidate.retryAfterMs;
31
+ const headers = candidate.headers ?? candidate.response?.headers;
32
+ if (headers instanceof Headers) {
33
+ const value = headers.get("retry-after");
34
+ if (!value) return void 0;
35
+ const seconds = Number(value.trim());
36
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
37
+ const date = Date.parse(value);
38
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : void 0;
39
+ }
40
+ if (headers && typeof headers === "object") {
41
+ const value = headers["retry-after"] ?? headers["Retry-After"];
42
+ if (typeof value !== "string") return void 0;
43
+ const seconds = Number(value.trim());
44
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
45
+ const date = Date.parse(value);
46
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : void 0;
47
+ }
48
+ return void 0;
49
+ }
21
50
  var AzureTtsSdkError = class extends AzureTtsError {
22
51
  constructor(errorDetails) {
23
52
  super(0, "Speech SDK", errorDetails, null);
@@ -128,6 +157,9 @@ var OUTPUT_FORMATS = {
128
157
  function resolveMimeType(outputFormat) {
129
158
  if (/(?:wav|wave|riff)/i.test(outputFormat)) return "audio/wav";
130
159
  if (/(?:mp3|mpeg)/i.test(outputFormat)) return "audio/mpeg";
160
+ if (/mulaw|mu-law/i.test(outputFormat)) return "audio/basic";
161
+ if (/alaw|a-law/i.test(outputFormat)) return "audio/alaw";
162
+ if (/siren/i.test(outputFormat)) return "audio/siren";
131
163
  if (/ogg/i.test(outputFormat)) return "audio/ogg";
132
164
  if (/webm/i.test(outputFormat)) return "audio/webm";
133
165
  if (/raw/i.test(outputFormat)) return "audio/L16";
@@ -156,6 +188,27 @@ function createSpeechConfig(config) {
156
188
  }
157
189
 
158
190
  // src/synthesis.ts
191
+ function computeChunkFingerprint(ssml, outputFormat = DEFAULT_OUTPUT_FORMAT) {
192
+ const readAttribute = (name) => {
193
+ const pattern = new RegExp(`(?:${name})\\s*=\\s*[\\"']([^\\"']*)`, "gi");
194
+ return [...ssml.matchAll(pattern)].map((match) => match[1] ?? "").join("|");
195
+ };
196
+ const payload = JSON.stringify({
197
+ ssml,
198
+ outputFormat,
199
+ voice: readAttribute("(?:name|voice)"),
200
+ language: readAttribute("(?:xml:lang|lang)"),
201
+ rate: readAttribute("rate"),
202
+ pitch: readAttribute("pitch")
203
+ });
204
+ let hash = 0xcbf29ce484222325n;
205
+ const mask = 0xffffffffffffffffn;
206
+ for (let index = 0; index < payload.length; index += 1) {
207
+ hash ^= BigInt(payload.charCodeAt(index));
208
+ hash = hash * 0x100000001b3n & mask;
209
+ }
210
+ return `fnv1a64-${hash.toString(16).padStart(16, "0")}`;
211
+ }
159
212
  function ascii(bytes, offset, value) {
160
213
  return [...value].every((character, index) => bytes[offset + index] === character.charCodeAt(0));
161
214
  }
@@ -195,9 +248,11 @@ function parseWav(buffer) {
195
248
  }
196
249
  return { chunks, data, format };
197
250
  }
198
- function formatNumber(format, pattern, fallback) {
199
- const match = pattern.exec(format);
200
- return match?.[1] ? Number(match[1]) : fallback;
251
+ function formatSampleRate(format) {
252
+ const match = /(?:^|-)(\d+)(khz|hz)(?:-|$)/i.exec(format);
253
+ if (!match?.[1] || !match[2]) return 0;
254
+ const value = Number(match[1]);
255
+ return match[2].toLowerCase() === "khz" ? value * 1e3 : value;
201
256
  }
202
257
  function formatChannels(format, fallback) {
203
258
  if (/stereo|2ch|dual/i.test(format)) return 2;
@@ -205,11 +260,13 @@ function formatChannels(format, fallback) {
205
260
  return fallback;
206
261
  }
207
262
  function formatAudioSpecification(format) {
208
- const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
263
+ const sampleRate = formatSampleRate(format);
209
264
  const channels = formatChannels(format, 0);
210
265
  const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
211
266
  const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1e3 : void 0;
212
- 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";
267
+ 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";
268
+ const bitDepthMatch = /(\d+)bit/i.exec(format);
269
+ const container = /(?:wav|wave|riff)/i.test(format) ? "riff-wave" : /mp3|mpeg/i.test(format) ? "mp3-raw" : /ogg/i.test(format) ? "ogg" : /webm/i.test(format) ? "webm" : /raw/i.test(format) ? "raw" : void 0;
213
270
  return {
214
271
  format,
215
272
  mimeType: resolveMimeType(format),
@@ -217,7 +274,10 @@ function formatAudioSpecification(format) {
217
274
  sampleRate,
218
275
  channels,
219
276
  ...bitrate ? { bitrate } : {},
220
- isCompressed: codec === "mp3" || codec === "opus" || codec === "silk"
277
+ ...bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {},
278
+ ...container ? { container } : {},
279
+ isVbr: /vbr/i.test(format),
280
+ isCompressed: codec !== "pcm" && codec !== "unknown"
221
281
  };
222
282
  }
223
283
  function parseMp3Specification(buffer, format) {
@@ -252,6 +312,8 @@ function parseMp3Specification(buffer, format) {
252
312
  sampleRate,
253
313
  channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
254
314
  bitrate: bitrateKbps * 1e3,
315
+ container: "mp3-raw",
316
+ isVbr: false,
255
317
  isCompressed: true
256
318
  };
257
319
  }
@@ -266,24 +328,43 @@ function inspectAudioSpecification(buffer, format) {
266
328
  const channels = view.getUint16(2, true);
267
329
  const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
268
330
  const formatCode = view.getUint16(0, true);
331
+ const namedCodec = formatAudioSpecification(format).codec;
332
+ const codec = formatCode === 1 ? "pcm" : formatCode === 6 ? "alaw" : formatCode === 7 ? "mulaw" : namedCodec === "siren" ? "siren" : "unknown";
269
333
  return {
270
334
  format,
271
335
  mimeType: "audio/wav",
272
- codec: formatCode === 1 ? "pcm" : "unknown",
336
+ codec,
273
337
  sampleRate,
274
338
  channels,
275
339
  ...sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {},
276
- isCompressed: formatCode !== 1
340
+ bitDepth: bitsPerSample,
341
+ container: "riff-wave",
342
+ isVbr: false,
343
+ isCompressed: codec !== "pcm" && codec !== "unknown"
277
344
  };
278
345
  }
279
346
  if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
280
- return formatAudioSpecification(format);
347
+ const specification = formatAudioSpecification(format);
348
+ if (specification.container === "raw") validateRawAudioBuffer(buffer, specification);
349
+ return specification;
350
+ }
351
+ function validateRawAudioBuffer(buffer, specification) {
352
+ if (specification.sampleRate <= 0 || specification.channels <= 0 || specification.bitDepth === void 0) {
353
+ throw new Error(`RAW audio format "${specification.format}" does not define a complete audio specification.`);
354
+ }
355
+ if (specification.codec === "siren" || specification.codec === "silk") return;
356
+ const bytesPerFrame = specification.channels * Math.ceil(specification.bitDepth / 8);
357
+ if (bytesPerFrame <= 0 || buffer.byteLength % bytesPerFrame !== 0) {
358
+ throw new Error(
359
+ `RAW audio buffer size ${buffer.byteLength} is not aligned to ${bytesPerFrame}-byte audio frames for "${specification.format}".`
360
+ );
361
+ }
281
362
  }
282
363
  function validateAudioSpecifications(specs) {
283
364
  const first = specs[0];
284
365
  if (!first) return;
285
366
  const mismatch = specs.find(
286
- (spec) => spec.sampleRate !== first.sampleRate || spec.channels !== first.channels || first.bitrate !== void 0 && spec.bitrate !== void 0 && spec.bitrate !== first.bitrate
367
+ (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
287
368
  );
288
369
  if (mismatch)
289
370
  throw new AudioFormatMismatchError(
@@ -366,6 +447,30 @@ function isWavFormat(format) {
366
447
  function isRawFormat(format) {
367
448
  return /^raw(?:-|$)/i.test(format);
368
449
  }
450
+ function validateMergedAudioBuffer(merged, format, buffers, inputSpecs, outputMimeType) {
451
+ if (!(merged instanceof ArrayBuffer) || merged.byteLength === 0) {
452
+ throw new MergeError("The custom audio merger returned an empty or invalid audio buffer.");
453
+ }
454
+ const specification = inspectAudioSpecification(merged, format);
455
+ if (!outputMimeType.trim()) throw new MergeError("The custom audio merger output MIME type cannot be empty.");
456
+ const firstInput = inputSpecs[0];
457
+ if (firstInput && (specification.sampleRate !== firstInput.sampleRate || specification.channels !== firstInput.channels || specification.codec !== firstInput.codec || firstInput.bitDepth !== void 0 && specification.bitDepth !== firstInput.bitDepth)) {
458
+ throw new AudioFormatMismatchError("The custom audio merger returned an incompatible audio stream.", [
459
+ ...inputSpecs,
460
+ specification
461
+ ]);
462
+ }
463
+ if (isRawFormat(format)) {
464
+ const expectedSize = buffers.reduce((total, input) => total + input.byteLength, 0);
465
+ if (merged.byteLength !== expectedSize) {
466
+ throw new MergeError(
467
+ `The custom audio merger returned ${merged.byteLength} bytes; ${expectedSize} were expected.`
468
+ );
469
+ }
470
+ validateRawAudioBuffer(merged, specification);
471
+ }
472
+ return specification;
473
+ }
369
474
  function resolveMergeAudioFormat(format) {
370
475
  if (isWavFormat(format)) return "wav";
371
476
  if (isMp3Format(format)) return "mp3";
@@ -418,7 +523,7 @@ function closeSpeechResources(speechConfig, synthesizer) {
418
523
  }
419
524
  }
420
525
  var ticksToMilliseconds = (ticks) => Math.max(0, ticks) / 1e4;
421
- async function synthesizeSsml(ssml, config) {
526
+ async function synthesizeSsmlOnce(ssml, config) {
422
527
  if (config.signal?.aborted) {
423
528
  throw new SynthesisCancelledError();
424
529
  }
@@ -486,9 +591,7 @@ async function synthesizeSsml(ssml, config) {
486
591
  };
487
592
  }
488
593
  if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) {
489
- const unmapped = { mappingStatus: "unmapped" };
490
- Object.defineProperty(unmapped, "mappingStatus", { value: "unmapped", enumerable: false });
491
- return unmapped;
594
+ return { mappingStatus: "unmapped" };
492
595
  }
493
596
  const value = text ?? "";
494
597
  let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? offsetHint : -1;
@@ -550,6 +653,13 @@ async function synthesizeSsml(ssml, config) {
550
653
  rejectWithError(err);
551
654
  return;
552
655
  }
656
+ let audioSpec;
657
+ try {
658
+ audioSpec = inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT);
659
+ } catch (error) {
660
+ rejectWithError(error);
661
+ return;
662
+ }
553
663
  settled = true;
554
664
  cleanup();
555
665
  closeResources();
@@ -570,8 +680,13 @@ async function synthesizeSsml(ssml, config) {
570
680
  ...config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
571
681
  ...requestId ? { requestId } : {}
572
682
  };
573
- if (event.mappingStatus === "unmapped")
683
+ if (event.mappingStatus === "unmapped") {
574
684
  Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
685
+ Object.defineProperty(mapped, "toJSON", {
686
+ value: () => ({ ...mapped, mappingStatus: "unmapped" }),
687
+ enumerable: false
688
+ });
689
+ }
575
690
  return mapped;
576
691
  };
577
692
  const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
@@ -580,8 +695,8 @@ async function synthesizeSsml(ssml, config) {
580
695
  resolve({
581
696
  audioData: result.audioData,
582
697
  durationMs,
583
- audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
584
- mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
698
+ audioSpec,
699
+ mimeType: config.outputMimeType ?? resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
585
700
  ...config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {},
586
701
  ...requestId ? { requestId } : {},
587
702
  ...sourceBoundaries.length > 0 ? { boundaries: sourceBoundaries, wordBoundary: sourceBoundaries, wordBoundaries: sourceBoundaries } : {},
@@ -594,10 +709,11 @@ async function synthesizeSsml(ssml, config) {
594
709
  abortHandler = () => rejectWithError(new SynthesisCancelledError());
595
710
  config.signal.addEventListener("abort", abortHandler, { once: true });
596
711
  }
597
- if (config.timeoutMs !== void 0 && config.timeoutMs > 0) {
712
+ const timeoutMs = config.timeouts?.perChunkMs ?? config.timeouts?.totalJobMs ?? config.timeoutMs;
713
+ if (timeoutMs !== void 0 && timeoutMs > 0) {
598
714
  timeout = setTimeout(
599
- () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeoutMs} ms.`)),
600
- config.timeoutMs
715
+ () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
716
+ timeoutMs
601
717
  );
602
718
  }
603
719
  synthesizer.speakSsmlAsync(ssml, cb, rejectWithError);
@@ -616,7 +732,9 @@ function isRetryableSynthesisError(error) {
616
732
  if (typeof status === "number") return status === 429 || status >= 500 && status < 600;
617
733
  return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
618
734
  }
619
- function retryDelay(options, retryAttempt) {
735
+ function retryDelay(options, retryAttempt, error) {
736
+ const retryAfterMs = getRetryAfterDelayMs(error);
737
+ if (retryAfterMs !== void 0) return retryAfterMs;
620
738
  const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
621
739
  return Math.floor(Math.random() * (base + 1));
622
740
  }
@@ -644,32 +762,102 @@ async function waitForRetry(delayMs, signal) {
644
762
  }
645
763
  });
646
764
  }
647
- async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
765
+ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry, deadlineAtMs) {
648
766
  const options = retryOptions ? {
649
767
  maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
650
768
  initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
651
- maxDelayMs: Math.max(0, retryOptions.maxDelayMs)
769
+ maxDelayMs: Math.max(0, retryOptions.maxDelayMs),
770
+ shouldRetry: retryOptions.shouldRetry
652
771
  } : void 0;
653
772
  let attempt = 0;
654
773
  while (true) {
655
774
  if (config.signal?.aborted) throw new SynthesisCancelledError();
656
775
  try {
657
- return await synthesizeSsml(ssml, config);
776
+ return await synthesizeSsmlOnce(ssml, config);
658
777
  } catch (error) {
659
- if (!options || attempt >= options.maxRetries || !isRetryableSynthesisError(error)) throw error;
778
+ if (!options || attempt >= options.maxRetries || !(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error)))
779
+ throw error;
660
780
  attempt += 1;
661
- const delayMs = retryDelay(options, attempt);
781
+ const delayMs = retryDelay(options, attempt, error);
782
+ const remainingMs = deadlineAtMs === void 0 ? void 0 : Math.max(0, deadlineAtMs - Date.now());
783
+ if (getRetryAfterDelayMs(error) !== void 0 && (delayMs > options.maxDelayMs || remainingMs !== void 0 && delayMs > remainingMs)) {
784
+ throw new SynthesisTimeoutError(
785
+ remainingMs === void 0 ? `Retry-After exceeded maxDelayMs (${options.maxDelayMs} ms).` : `Retry-After exceeded the remaining total job timeout (${remainingMs} ms).`
786
+ );
787
+ }
662
788
  onRetry(attempt, delayMs);
663
- await waitForRetry(delayMs, config.signal);
789
+ await waitForRetry(Math.min(delayMs, remainingMs ?? delayMs), config.signal);
664
790
  }
665
791
  }
666
792
  }
793
+ async function synthesizeSsml(ssml, config) {
794
+ const totalJobMs = config.timeouts?.totalJobMs;
795
+ const deadlineAtMs = totalJobMs !== void 0 && totalJobMs > 0 ? Date.now() + totalJobMs : void 0;
796
+ if (!config.retryOptions) return synthesizeSsmlOnce(ssml, config);
797
+ return synthesizeWithRetry(ssml, config, config.retryOptions, () => void 0, deadlineAtMs);
798
+ }
799
+ function createAbortScope(parent, timeoutMs) {
800
+ const controller = new AbortController();
801
+ let didTimeout = false;
802
+ const onAbort = () => controller.abort();
803
+ if (parent?.aborted) controller.abort();
804
+ parent?.addEventListener("abort", onAbort, { once: true });
805
+ const timer = timeoutMs !== void 0 && timeoutMs > 0 ? setTimeout(() => {
806
+ didTimeout = true;
807
+ controller.abort();
808
+ }, timeoutMs) : void 0;
809
+ return {
810
+ signal: controller.signal,
811
+ timedOut: () => didTimeout,
812
+ dispose: () => {
813
+ if (timer) clearTimeout(timer);
814
+ parent?.removeEventListener("abort", onAbort);
815
+ },
816
+ abort: () => controller.abort()
817
+ };
818
+ }
819
+ async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs, onRetry, deadlineAtMs) {
820
+ const scope = createAbortScope(config.signal, timeoutMs);
821
+ try {
822
+ return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry, deadlineAtMs);
823
+ } catch (error) {
824
+ if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
825
+ throw error;
826
+ } finally {
827
+ scope.dispose();
828
+ }
829
+ }
667
830
  async function synthesizeSsmlChunks(chunks, config) {
668
- const results = new Array(chunks.length);
669
831
  const totalChunks = chunks.length;
832
+ const inputs = chunks.map((chunk) => typeof chunk === "string" ? { ssml: chunk } : chunk);
833
+ const fingerprints = inputs.map(
834
+ (chunk) => computeChunkFingerprint(chunk.ssml, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT)
835
+ );
836
+ const results = new Array(totalChunks);
837
+ const cachedChunks = new Map((config.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
838
+ const invalidCachedIndices = /* @__PURE__ */ new Set();
839
+ const chunkStates = inputs.map((_chunk, chunkIndex) => ({
840
+ chunkIndex,
841
+ status: "pending",
842
+ canResume: true
843
+ }));
844
+ for (const [index, cached] of cachedChunks) {
845
+ if (index < 0 || index >= totalChunks) continue;
846
+ const isValid = config.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index];
847
+ if (isValid) {
848
+ results[index] = { ...cached };
849
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
850
+ } else {
851
+ invalidCachedIndices.add(index);
852
+ }
853
+ }
854
+ const requestedIndices = config.resumeChunkIndices ? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks)) : void 0;
855
+ const shouldSynthesize = (index) => (!cachedChunks.has(index) || invalidCachedIndices.has(index)) && (requestedIndices === void 0 || requestedIndices.has(index) || invalidCachedIndices.has(index));
856
+ const jobStartedAt = Date.now();
857
+ const jobDeadlineAt = config.timeouts?.totalJobMs !== void 0 && config.timeouts.totalJobMs > 0 ? jobStartedAt + config.timeouts.totalJobMs : void 0;
858
+ const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
670
859
  const report = (event) => config.onProgress?.(event);
671
- for (const [index, chunk] of chunks.entries()) {
672
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
860
+ for (const [index, input] of inputs.entries()) {
673
861
  report({
674
862
  currentChunk: index,
675
863
  totalChunks,
@@ -680,15 +868,18 @@ async function synthesizeSsmlChunks(chunks, config) {
680
868
  durationMs: 0
681
869
  });
682
870
  }
683
- let completed = 0;
871
+ let completed = [...results].filter((result) => result !== void 0).length;
684
872
  let nextIndex = 0;
685
873
  const concurrency = resolveConcurrency(config.concurrency, chunks.length);
874
+ let firstError;
875
+ const failedIndices = /* @__PURE__ */ new Set();
686
876
  const worker = async () => {
687
877
  while (true) {
688
878
  const index = nextIndex++;
689
879
  if (index >= chunks.length) return;
690
- const chunk = chunks[index];
691
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
880
+ if (!shouldSynthesize(index)) continue;
881
+ if (firstError && config.cancelOnFailure !== false) return;
882
+ const input = inputs[index];
692
883
  report({
693
884
  currentChunk: completed,
694
885
  totalChunks,
@@ -700,10 +891,11 @@ async function synthesizeSsmlChunks(chunks, config) {
700
891
  });
701
892
  const startedAt = Date.now();
702
893
  try {
703
- const result = await synthesizeWithRetry(
894
+ const result = await synthesizeChunkWithTimeout(
704
895
  input.ssml,
705
896
  {
706
897
  ...config,
898
+ signal: scope.signal,
707
899
  ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
708
900
  ...input.sourceNodePath ?? config.sourceNodePath ? { sourceNodePath: [...input.sourceNodePath ?? config.sourceNodePath ?? []] } : {},
709
901
  ...input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {},
@@ -712,6 +904,7 @@ async function synthesizeSsmlChunks(chunks, config) {
712
904
  onProgress: void 0
713
905
  },
714
906
  config.retryOptions,
907
+ config.timeouts?.chunkWithRetriesMs ?? config.timeouts?.perChunkMs ?? config.timeoutMs,
715
908
  (retryAttempt, nextRetryDelayMs) => report({
716
909
  currentChunk: completed,
717
910
  totalChunks,
@@ -723,9 +916,11 @@ async function synthesizeSsmlChunks(chunks, config) {
723
916
  retryAttempt,
724
917
  nextRetryDelayMs,
725
918
  isRetrying: true
726
- })
919
+ }),
920
+ jobDeadlineAt
727
921
  );
728
922
  results[index] = result;
923
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result };
729
924
  completed += 1;
730
925
  report({
731
926
  currentChunk: completed,
@@ -737,6 +932,16 @@ async function synthesizeSsmlChunks(chunks, config) {
737
932
  durationMs: Date.now() - startedAt
738
933
  });
739
934
  } catch (error) {
935
+ const wasCancelled = firstError !== void 0 || scope.signal.aborted && !scope.timedOut();
936
+ firstError ?? (firstError = scope.timedOut() ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`) : error);
937
+ if (!wasCancelled) failedIndices.add(index);
938
+ chunkStates[index] = {
939
+ chunkIndex: index,
940
+ status: wasCancelled ? "cancelled" : "failed",
941
+ isOriginalFailure: !wasCancelled,
942
+ canResume: true,
943
+ error
944
+ };
740
945
  report({
741
946
  currentChunk: completed,
742
947
  totalChunks,
@@ -747,16 +952,49 @@ async function synthesizeSsmlChunks(chunks, config) {
747
952
  durationMs: Date.now() - startedAt,
748
953
  error
749
954
  });
750
- throw error;
955
+ if (config.cancelOnFailure !== false) scope.abort();
956
+ return;
751
957
  }
752
958
  }
753
959
  };
754
- await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
755
- const orderedResults = results.filter((result) => result !== void 0);
756
- return mergeSynthesisResults(orderedResults, {
757
- format: config.outputFormat ?? DEFAULT_OUTPUT_FORMAT,
758
- signal: config.signal
759
- });
960
+ try {
961
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
962
+ if (firstError) throw firstError;
963
+ const orderedResults = results.filter((result) => result !== void 0);
964
+ return await mergeSynthesisResults(orderedResults, {
965
+ format: config.outputFormat ?? DEFAULT_OUTPUT_FORMAT,
966
+ signal: scope.signal,
967
+ customMerger: config.customMerger,
968
+ outputMimeType: config.outputMimeType,
969
+ postMergeValidator: config.postMergeValidator
970
+ });
971
+ } catch (error) {
972
+ if (firstError && config.cancelOnFailure !== false) {
973
+ for (const [chunkIndex, state] of chunkStates.entries()) {
974
+ if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
975
+ chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
976
+ }
977
+ }
978
+ }
979
+ const synthesizedChunks = results.flatMap(
980
+ (result, chunkIndex) => result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : []
981
+ );
982
+ const partial = {
983
+ synthesizedChunks,
984
+ completedChunks: synthesizedChunks,
985
+ pendingChunkIndices: chunkStates.flatMap(
986
+ (state) => state.status === "pending" || state.status === "cancelled" || state.status === "failed" ? [state.chunkIndex] : []
987
+ ),
988
+ failedChunkIndices: [...failedIndices],
989
+ cancelledChunkIndices: chunkStates.filter((state) => state.status === "cancelled").map((state) => state.chunkIndex),
990
+ chunkStates,
991
+ totalChunks
992
+ };
993
+ if (error && typeof error === "object") error.partialResult = partial;
994
+ throw error;
995
+ } finally {
996
+ scope.dispose();
997
+ }
760
998
  }
761
999
  function createMergedResult(results, audioData, format, audioSpec, outputMimeType) {
762
1000
  const boundaries = [];
@@ -847,16 +1085,26 @@ function mergeSynthesisResults(results, options) {
847
1085
  })
848
1086
  ).then((merged) => {
849
1087
  if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
850
- if (!(merged instanceof ArrayBuffer) || buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
851
- throw new MergeError("The custom audio merger returned an invalid audio buffer.");
852
1088
  if (signal.aborted) throw new SynthesisCancelledError();
853
- return createMergedResult(
854
- results,
1089
+ const mergedSpec = validateMergedAudioBuffer(
855
1090
  merged,
856
1091
  format,
857
- inspectAudioSpecification(merged, format),
858
- resolvedOptions.outputMimeType
1092
+ buffers,
1093
+ inputSpecs,
1094
+ resolvedOptions.outputMimeType ?? resolveMimeType(format)
859
1095
  );
1096
+ const result = createMergedResult(results, merged, format, mergedSpec, resolvedOptions.outputMimeType);
1097
+ return Promise.resolve(
1098
+ resolvedOptions.postMergeValidator?.(result, {
1099
+ format,
1100
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
1101
+ inputSpecs,
1102
+ signal
1103
+ })
1104
+ ).then((valid) => {
1105
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1106
+ return result;
1107
+ });
860
1108
  }).catch((error) => {
861
1109
  if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
862
1110
  throw error;
@@ -864,13 +1112,28 @@ function mergeSynthesisResults(results, options) {
864
1112
  });
865
1113
  }
866
1114
  try {
867
- return createMergedResult(
1115
+ const result = createMergedResult(
868
1116
  results,
869
1117
  mergeAudioBuffers(buffers, { format }),
870
1118
  format,
871
1119
  inputSpecs[0],
872
1120
  resolvedOptions.outputMimeType
873
1121
  );
1122
+ if (resolvedOptions.postMergeValidator) {
1123
+ const validation = resolvedOptions.postMergeValidator(result, {
1124
+ format,
1125
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
1126
+ inputSpecs,
1127
+ signal
1128
+ });
1129
+ if (validation instanceof Promise)
1130
+ return validation.then((valid) => {
1131
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1132
+ return result;
1133
+ });
1134
+ if (validation === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1135
+ }
1136
+ return result;
874
1137
  } catch (error) {
875
1138
  if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
876
1139
  throw error;
@@ -895,8 +1158,52 @@ var ChunkValidationError = class extends Error {
895
1158
  this.diagnostics = diagnostics;
896
1159
  }
897
1160
  };
898
- function failure(error) {
899
- return { ok: false, success: false, status: error.kind, error };
1161
+ var BatchChunkValidationError = class extends ChunkValidationError {
1162
+ constructor(chunkDiagnostics) {
1163
+ const first = chunkDiagnostics[0];
1164
+ super(first?.chunkIndex ?? -1, first?.diagnostics ?? []);
1165
+ this.name = "BatchChunkValidationError";
1166
+ this.message = `SSML validation failed for ${chunkDiagnostics.length} chunk(s); the Azure Speech API was not called.`;
1167
+ this.chunkDiagnostics = chunkDiagnostics;
1168
+ this.totalErrorCount = chunkDiagnostics.reduce((total, chunk) => total + chunk.diagnostics.length, 0);
1169
+ this.errorCount = this.totalErrorCount;
1170
+ this.totalErrors = this.totalErrorCount;
1171
+ }
1172
+ };
1173
+ function failure(error, partialResult) {
1174
+ return {
1175
+ ok: false,
1176
+ success: false,
1177
+ status: error.kind,
1178
+ error,
1179
+ ...partialResult ? { partialResult } : {}
1180
+ };
1181
+ }
1182
+ function partialResultFrom(error) {
1183
+ if (!error || typeof error !== "object") return void 0;
1184
+ const partial = error.partialResult;
1185
+ if (!partial || typeof partial !== "object") return void 0;
1186
+ return partial;
1187
+ }
1188
+ function createSafeAbortScope(parent, timeoutMs) {
1189
+ const controller = new AbortController();
1190
+ let didTimeout = false;
1191
+ const onAbort = () => controller.abort();
1192
+ if (parent?.aborted) controller.abort();
1193
+ parent?.addEventListener("abort", onAbort, { once: true });
1194
+ const timer = timeoutMs !== void 0 && timeoutMs > 0 ? setTimeout(() => {
1195
+ didTimeout = true;
1196
+ controller.abort();
1197
+ }, timeoutMs) : void 0;
1198
+ return {
1199
+ signal: controller.signal,
1200
+ timedOut: () => didTimeout,
1201
+ dispose: () => {
1202
+ if (timer) clearTimeout(timer);
1203
+ parent?.removeEventListener("abort", onAbort);
1204
+ },
1205
+ abort: () => controller.abort()
1206
+ };
900
1207
  }
901
1208
  function isRetryable(error) {
902
1209
  if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
@@ -911,16 +1218,20 @@ function delayForRetry(options, attempt) {
911
1218
  const base = Math.min(maxDelay, Math.max(0, options.initialDelayMs) * 2 ** Math.max(0, attempt - 1));
912
1219
  return Math.floor(Math.random() * (base + 1));
913
1220
  }
1221
+ function retryDelayForError(options, attempt, error) {
1222
+ return getRetryAfterDelayMs(error) ?? delayForRetry(options, attempt);
1223
+ }
914
1224
  function resolveConcurrency2(value, total) {
915
1225
  if (value === void 0) return 1;
916
1226
  if (value === Infinity) return Math.max(1, total);
917
1227
  return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
918
1228
  }
919
- async function retryableSynthesis(synthesize, options, signal, onRetry) {
1229
+ async function retryableSynthesis(synthesize, options, signal, onRetry, deadlineAtMs) {
920
1230
  const retry = options ? {
921
1231
  maxRetries: Math.max(0, Math.floor(options.maxRetries)),
922
1232
  initialDelayMs: options.initialDelayMs,
923
- maxDelayMs: options.maxDelayMs
1233
+ maxDelayMs: options.maxDelayMs,
1234
+ shouldRetry: options.shouldRetry
924
1235
  } : void 0;
925
1236
  let attempt = 0;
926
1237
  while (true) {
@@ -928,9 +1239,15 @@ async function retryableSynthesis(synthesize, options, signal, onRetry) {
928
1239
  try {
929
1240
  return await synthesize();
930
1241
  } catch (error) {
931
- if (!retry || attempt >= retry.maxRetries || !isRetryable(error)) throw error;
1242
+ if (!retry || attempt >= retry.maxRetries || !(retry.shouldRetry?.(error, attempt + 1) ?? isRetryable(error)))
1243
+ throw error;
932
1244
  attempt += 1;
933
- const delayMs = delayForRetry(retry, attempt);
1245
+ const delayMs = retryDelayForError(retry, attempt, error);
1246
+ const retryAfterMs = getRetryAfterDelayMs(error);
1247
+ const remainingMs = deadlineAtMs === void 0 ? void 0 : Math.max(0, deadlineAtMs - Date.now());
1248
+ if (retryAfterMs !== void 0 && (retryAfterMs > retry.maxDelayMs || remainingMs !== void 0 && retryAfterMs > remainingMs)) {
1249
+ throw new Error("Speech synthesis timed out because Retry-After exceeded the available retry budget.");
1250
+ }
934
1251
  onRetry(attempt, delayMs);
935
1252
  if (delayMs > 0)
936
1253
  await new Promise((resolve, reject) => {
@@ -954,7 +1271,7 @@ function sharedValidationOptions(options, signal) {
954
1271
  const runner = createAzureUrlValidatorRunner(validator, {
955
1272
  ...options.urlValidation ?? {},
956
1273
  ...options.urlValidatorConcurrency !== void 0 ? { concurrency: options.urlValidatorConcurrency } : {},
957
- ...options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
1274
+ ...options.timeouts?.urlValidationMs !== void 0 ? { timeoutMs: options.timeouts.urlValidationMs } : options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
958
1275
  ...signal ? { signal } : {},
959
1276
  ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
960
1277
  });
@@ -978,20 +1295,31 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
978
1295
  diagnostics: errors
979
1296
  });
980
1297
  }
1298
+ const jobScope = options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts.totalJobMs) : void 0;
981
1299
  try {
982
1300
  return {
983
1301
  ok: true,
984
1302
  success: true,
985
1303
  status: "success",
986
- value: await client.synthesizeSsml(ssml, { signal: options.signal })
1304
+ value: await client.synthesizeSsml(ssml, {
1305
+ signal: jobScope?.signal ?? options.signal,
1306
+ timeoutMs: options.timeouts?.perChunkMs,
1307
+ timeouts: options.timeouts
1308
+ })
987
1309
  };
988
1310
  } catch (error) {
1311
+ if (jobScope?.timedOut()) return failure(toSynthesisError(new Error("Speech synthesis timed out.")));
989
1312
  const synthesisError = toSynthesisError(error);
990
1313
  return failure(synthesisError);
1314
+ } finally {
1315
+ jobScope?.dispose();
991
1316
  }
992
1317
  }
993
1318
  async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
994
- const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
1319
+ const validationOptions = sharedValidationOptions(
1320
+ { ...options.validation ?? options, timeouts: options.timeouts },
1321
+ options.signal
1322
+ );
995
1323
  if (options.signal?.aborted) {
996
1324
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
997
1325
  return failure(error);
@@ -1025,16 +1353,17 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1025
1353
  return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
1026
1354
  })
1027
1355
  );
1028
- const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
1029
1356
  if (options.signal?.aborted) {
1030
1357
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
1031
1358
  return failure(error);
1032
1359
  }
1033
- if (firstInvalidIndex >= 0) {
1034
- const error = new ChunkValidationError(firstInvalidIndex, validations[firstInvalidIndex] ?? []);
1035
- pending(firstInvalidIndex, "failed", error);
1360
+ const chunkDiagnostics = validations.map((diagnostics, chunkIndex) => ({ chunkIndex, diagnostics })).filter((entry) => entry.diagnostics.length > 0);
1361
+ if (chunkDiagnostics.length > 0) {
1362
+ const error = new BatchChunkValidationError(chunkDiagnostics);
1363
+ for (const entry of chunkDiagnostics) pending(entry.chunkIndex, "failed", error);
1036
1364
  return failure(error);
1037
1365
  }
1366
+ let fallbackJobScope;
1038
1367
  try {
1039
1368
  if (client.synthesizeChunks) {
1040
1369
  const normalizedChunks = chunks.map((chunk) => {
@@ -1046,20 +1375,57 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1046
1375
  outputFormat: options.outputFormat,
1047
1376
  signal: options.signal,
1048
1377
  timeoutMs: options.timeoutMs,
1378
+ timeouts: options.timeouts,
1049
1379
  sourceNodePath: options.sourceNodePath,
1050
1380
  concurrency: options.concurrency,
1051
- retryOptions: options.retryOptions
1381
+ retryOptions: options.retryOptions,
1382
+ cancelOnFailure: options.cancelOnFailure,
1383
+ resumeChunks: options.resumeChunks,
1384
+ resumeChunkIndices: options.resumeChunkIndices,
1385
+ customMerger: options.customMerger,
1386
+ outputMimeType: options.outputMimeType,
1387
+ postMergeValidator: options.postMergeValidator,
1388
+ resumeValidation: options.resumeValidation
1052
1389
  });
1053
1390
  return { ok: true, success: true, status: "success", value };
1054
1391
  }
1392
+ const inputs = chunks.map((chunk) => typeof chunk === "string" ? { ssml: chunk } : chunk);
1393
+ const fingerprints = inputs.map((chunk) => computeChunkFingerprint(chunk.ssml, options.outputFormat));
1055
1394
  const results = new Array(chunks.length);
1056
- let completed = 0;
1395
+ const chunkStates = inputs.map((_chunk, chunkIndex) => ({
1396
+ chunkIndex,
1397
+ status: "pending",
1398
+ canResume: true
1399
+ }));
1400
+ const cachedChunks = new Map((options.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
1401
+ const invalidCachedIndices = /* @__PURE__ */ new Set();
1402
+ for (const [index, cached] of cachedChunks) {
1403
+ if (index < 0 || index >= chunks.length) continue;
1404
+ if (options.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index]) {
1405
+ results[index] = cached;
1406
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: cached };
1407
+ } else invalidCachedIndices.add(index);
1408
+ }
1409
+ const requestedIndices = options.resumeChunkIndices ? new Set(options.resumeChunkIndices.filter((index) => index >= 0 && index < chunks.length)) : void 0;
1410
+ const shouldSynthesize = (index) => (!cachedChunks.has(index) || invalidCachedIndices.has(index)) && (requestedIndices === void 0 || requestedIndices.has(index) || invalidCachedIndices.has(index));
1411
+ const jobStartedAt = Date.now();
1412
+ const jobDeadlineAt = options.timeouts?.totalJobMs !== void 0 && options.timeouts.totalJobMs > 0 ? jobStartedAt + options.timeouts.totalJobMs : void 0;
1413
+ const jobScope = chunks.length > 1 || options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs) : void 0;
1414
+ fallbackJobScope = jobScope;
1415
+ const failedIndices = /* @__PURE__ */ new Set();
1416
+ let firstError;
1417
+ let completed = [...results].filter((result) => result !== void 0).length;
1057
1418
  let nextIndex = 0;
1058
1419
  const concurrency = resolveConcurrency2(options.concurrency, chunks.length);
1059
1420
  const worker = async () => {
1060
1421
  while (true) {
1061
1422
  const index = nextIndex++;
1062
1423
  if (index >= chunks.length) return;
1424
+ if (!shouldSynthesize(index)) continue;
1425
+ if (firstError && options.cancelOnFailure !== false) {
1426
+ chunkStates[index] = { chunkIndex: index, status: "cancelled", isOriginalFailure: false, canResume: true };
1427
+ return;
1428
+ }
1063
1429
  const chunk = chunks[index];
1064
1430
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
1065
1431
  const sourceNodePath = input.sourceNodePath;
@@ -1067,28 +1433,41 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1067
1433
  pending(index, "synthesizing");
1068
1434
  const startedAt = Date.now();
1069
1435
  try {
1070
- const result = await retryableSynthesis(
1071
- () => client.synthesizeSsml(input.ssml, {
1072
- outputFormat: options.outputFormat,
1073
- signal: options.signal,
1074
- timeoutMs: options.timeoutMs,
1075
- sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
1076
- }),
1077
- options.retryOptions,
1078
- options.signal,
1079
- (retryAttempt, nextRetryDelayMs) => options.onProgress?.({
1080
- currentChunk: completed,
1081
- totalChunks: chunks.length,
1082
- percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1083
- chunkIndex: index,
1084
- originalTextRange: input.originalTextRange,
1085
- status: "synthesizing",
1086
- durationMs: Date.now() - startedAt,
1087
- retryAttempt,
1088
- nextRetryDelayMs,
1089
- isRetrying: true
1090
- })
1091
- );
1436
+ const chunkTimeout = options.timeouts?.chunkWithRetriesMs ?? options.timeouts?.perChunkMs;
1437
+ const chunkScope = chunkTimeout !== void 0 || jobScope ? createSafeAbortScope(jobScope?.signal ?? options.signal, chunkTimeout ?? options.timeoutMs) : void 0;
1438
+ const chunkSignal = chunkScope?.signal ?? options.signal;
1439
+ let result;
1440
+ try {
1441
+ result = await retryableSynthesis(
1442
+ () => client.synthesizeSsml(input.ssml, {
1443
+ outputFormat: options.outputFormat,
1444
+ signal: chunkSignal,
1445
+ timeoutMs: options.timeouts?.perChunkMs ?? options.timeoutMs,
1446
+ sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
1447
+ }),
1448
+ options.retryOptions,
1449
+ chunkSignal,
1450
+ (retryAttempt, nextRetryDelayMs) => options.onProgress?.({
1451
+ currentChunk: completed,
1452
+ totalChunks: chunks.length,
1453
+ percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1454
+ chunkIndex: index,
1455
+ originalTextRange: input.originalTextRange,
1456
+ status: "synthesizing",
1457
+ durationMs: Date.now() - startedAt,
1458
+ retryAttempt,
1459
+ nextRetryDelayMs,
1460
+ isRetrying: true
1461
+ }),
1462
+ jobDeadlineAt
1463
+ );
1464
+ } catch (error) {
1465
+ if (chunkScope?.timedOut())
1466
+ throw new Error(`Speech synthesis timed out after ${chunkTimeout ?? options.timeoutMs} ms.`);
1467
+ throw error;
1468
+ } finally {
1469
+ chunkScope?.dispose();
1470
+ }
1092
1471
  results[index] = {
1093
1472
  ...result,
1094
1473
  ...input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {},
@@ -1132,6 +1511,7 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1132
1511
  }))
1133
1512
  } : {}
1134
1513
  };
1514
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
1135
1515
  completed += 1;
1136
1516
  options.onProgress?.({
1137
1517
  currentChunk: completed,
@@ -1143,6 +1523,16 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1143
1523
  durationMs: Date.now() - startedAt
1144
1524
  });
1145
1525
  } catch (error) {
1526
+ const wasCancelled = firstError !== void 0 || Boolean(jobScope?.signal.aborted && !jobScope?.timedOut());
1527
+ firstError ?? (firstError = error);
1528
+ if (!wasCancelled) failedIndices.add(index);
1529
+ chunkStates[index] = {
1530
+ chunkIndex: index,
1531
+ status: wasCancelled ? "cancelled" : "failed",
1532
+ isOriginalFailure: !wasCancelled,
1533
+ canResume: true,
1534
+ error
1535
+ };
1146
1536
  options.onProgress?.({
1147
1537
  currentChunk: completed,
1148
1538
  totalChunks: chunks.length,
@@ -1153,24 +1543,55 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1153
1543
  durationMs: Date.now() - startedAt,
1154
1544
  error
1155
1545
  });
1156
- throw error;
1546
+ if (options.cancelOnFailure !== false) jobScope?.abort();
1547
+ return;
1157
1548
  }
1158
1549
  }
1159
1550
  };
1160
1551
  await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
1552
+ if (firstError && options.cancelOnFailure !== false) {
1553
+ for (const [chunkIndex, state] of chunkStates.entries()) {
1554
+ if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
1555
+ chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
1556
+ }
1557
+ }
1558
+ }
1559
+ if (failedIndices.size > 0) {
1560
+ const error = firstError ?? new Error("One or more SSML chunks failed to synthesize.");
1561
+ const synthesizedChunks = results.flatMap(
1562
+ (result, chunkIndex) => result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : []
1563
+ );
1564
+ error.partialResult = {
1565
+ synthesizedChunks,
1566
+ completedChunks: synthesizedChunks,
1567
+ pendingChunkIndices: chunkStates.flatMap(
1568
+ (state) => state.status === "pending" || state.status === "cancelled" || state.status === "failed" ? [state.chunkIndex] : []
1569
+ ),
1570
+ failedChunkIndices: [...failedIndices],
1571
+ cancelledChunkIndices: chunkStates.filter((state) => state.status === "cancelled").map((state) => state.chunkIndex),
1572
+ chunkStates,
1573
+ totalChunks: chunks.length
1574
+ };
1575
+ throw error;
1576
+ }
1161
1577
  const orderedResults = results.filter((result) => result !== void 0);
1162
1578
  return {
1163
1579
  ok: true,
1164
1580
  success: true,
1165
1581
  status: "success",
1166
- value: mergeSynthesisResults(orderedResults, {
1582
+ value: await mergeSynthesisResults(orderedResults, {
1167
1583
  format: options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3",
1168
- signal: options.signal
1584
+ signal: jobScope?.signal ?? options.signal,
1585
+ customMerger: options.customMerger,
1586
+ outputMimeType: options.outputMimeType,
1587
+ postMergeValidator: options.postMergeValidator
1169
1588
  })
1170
1589
  };
1171
1590
  } catch (error) {
1172
1591
  const synthesisError = toSynthesisError(error);
1173
- return failure(synthesisError);
1592
+ return failure(synthesisError, partialResultFrom(error));
1593
+ } finally {
1594
+ fallbackJobScope?.dispose();
1174
1595
  }
1175
1596
  }
1176
1597
  function withValidationSignal(options, signal) {
@@ -1191,14 +1612,23 @@ var AzureTtsClient = class {
1191
1612
  __privateSet(this, _options, options);
1192
1613
  }
1193
1614
  async synthesize(ssml) {
1194
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1615
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
1195
1616
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
1196
1617
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
1197
- const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs };
1618
+ const config = {
1619
+ endpoint,
1620
+ region,
1621
+ subscriptionKey,
1622
+ outputFormat,
1623
+ signal,
1624
+ timeoutMs,
1625
+ timeouts,
1626
+ retryOptions: __privateGet(this, _options).retryOptions
1627
+ };
1198
1628
  return synthesizeSpeech(ssml, config);
1199
1629
  }
1200
1630
  async synthesizeSsml(ssml, options = {}) {
1201
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1631
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
1202
1632
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
1203
1633
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
1204
1634
  return synthesizeSsml(ssml, {
@@ -1208,13 +1638,20 @@ var AzureTtsClient = class {
1208
1638
  outputFormat: options.outputFormat ?? outputFormat,
1209
1639
  signal: options.signal ?? signal,
1210
1640
  timeoutMs: options.timeoutMs ?? timeoutMs,
1641
+ timeouts: options.timeouts ?? timeouts,
1211
1642
  sourceNodePath: options.sourceNodePath,
1212
1643
  sourceTextSegments: options.sourceTextSegments,
1213
- sourceMarkers: options.sourceMarkers
1644
+ sourceMarkers: options.sourceMarkers,
1645
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
1646
+ cancelOnFailure: options.cancelOnFailure ?? __privateGet(this, _options).cancelOnFailure,
1647
+ customMerger: options.customMerger ?? __privateGet(this, _options).customMerger,
1648
+ outputMimeType: options.outputMimeType ?? __privateGet(this, _options).outputMimeType,
1649
+ postMergeValidator: options.postMergeValidator ?? __privateGet(this, _options).postMergeValidator,
1650
+ resumeValidation: options.resumeValidation ?? __privateGet(this, _options).resumeValidation
1214
1651
  });
1215
1652
  }
1216
1653
  async synthesizeChunks(chunks, options = {}) {
1217
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1654
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
1218
1655
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
1219
1656
  return synthesizeSsmlChunks(chunks, {
1220
1657
  endpoint,
@@ -1223,10 +1660,18 @@ var AzureTtsClient = class {
1223
1660
  outputFormat: options.outputFormat ?? outputFormat,
1224
1661
  signal: options.signal ?? signal,
1225
1662
  timeoutMs: options.timeoutMs ?? timeoutMs,
1663
+ timeouts: options.timeouts ?? timeouts,
1226
1664
  sourceNodePath: options.sourceNodePath,
1227
1665
  onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1228
1666
  concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1229
- retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
1667
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
1668
+ cancelOnFailure: options.cancelOnFailure ?? __privateGet(this, _options).cancelOnFailure,
1669
+ resumeChunks: options.resumeChunks,
1670
+ resumeChunkIndices: options.resumeChunkIndices,
1671
+ customMerger: options.customMerger ?? __privateGet(this, _options).customMerger,
1672
+ outputMimeType: options.outputMimeType ?? __privateGet(this, _options).outputMimeType,
1673
+ postMergeValidator: options.postMergeValidator ?? __privateGet(this, _options).postMergeValidator,
1674
+ resumeValidation: options.resumeValidation ?? __privateGet(this, _options).resumeValidation
1230
1675
  });
1231
1676
  }
1232
1677
  async synthesizeSsmlSafe(ssml, options = {}) {
@@ -1238,6 +1683,7 @@ var AzureTtsClient = class {
1238
1683
  outputFormat: options.outputFormat ?? __privateGet(this, _options).outputFormat,
1239
1684
  signal: options.signal ?? __privateGet(this, _options).signal,
1240
1685
  timeoutMs: options.timeoutMs ?? __privateGet(this, _options).timeoutMs,
1686
+ timeouts: options.timeouts ?? __privateGet(this, _options).timeouts,
1241
1687
  onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1242
1688
  concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1243
1689
  retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
@@ -1331,7 +1777,9 @@ async function fetchAzureVoiceCatalog(options) {
1331
1777
  voiceCount: sortedVoices.length,
1332
1778
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1333
1779
  apiVersion: AZURE_VOICE_API_VERSION,
1334
- regions
1780
+ regions,
1781
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1e3).toISOString(),
1782
+ regionDiffs: {}
1335
1783
  }
1336
1784
  };
1337
1785
  }
@@ -1340,6 +1788,7 @@ export {
1340
1788
  AzureTtsClient,
1341
1789
  AzureTtsError,
1342
1790
  AzureTtsSdkError,
1791
+ BatchChunkValidationError,
1343
1792
  ChunkValidationError,
1344
1793
  DEFAULT_OUTPUT_FORMAT,
1345
1794
  MergeError,
@@ -1347,7 +1796,9 @@ export {
1347
1796
  SynthesisTimeoutError,
1348
1797
  UnsupportedMergeFormatError,
1349
1798
  canMergeAudioFormat,
1799
+ computeChunkFingerprint,
1350
1800
  fetchAzureVoiceCatalog,
1801
+ getRetryAfterDelayMs,
1351
1802
  inspectAudioSpecification,
1352
1803
  mergeAudioBuffers,
1353
1804
  mergeSynthesisResults,