@ssml-builder-js/azure-tts-client 2.17.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
@@ -157,6 +157,9 @@ var OUTPUT_FORMATS = {
157
157
  function resolveMimeType(outputFormat) {
158
158
  if (/(?:wav|wave|riff)/i.test(outputFormat)) return "audio/wav";
159
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";
160
163
  if (/ogg/i.test(outputFormat)) return "audio/ogg";
161
164
  if (/webm/i.test(outputFormat)) return "audio/webm";
162
165
  if (/raw/i.test(outputFormat)) return "audio/L16";
@@ -185,6 +188,27 @@ function createSpeechConfig(config) {
185
188
  }
186
189
 
187
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
+ }
188
212
  function ascii(bytes, offset, value) {
189
213
  return [...value].every((character, index) => bytes[offset + index] === character.charCodeAt(0));
190
214
  }
@@ -224,9 +248,11 @@ function parseWav(buffer) {
224
248
  }
225
249
  return { chunks, data, format };
226
250
  }
227
- function formatNumber(format, pattern, fallback) {
228
- const match = pattern.exec(format);
229
- 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;
230
256
  }
231
257
  function formatChannels(format, fallback) {
232
258
  if (/stereo|2ch|dual/i.test(format)) return 2;
@@ -234,11 +260,11 @@ function formatChannels(format, fallback) {
234
260
  return fallback;
235
261
  }
236
262
  function formatAudioSpecification(format) {
237
- const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
263
+ const sampleRate = formatSampleRate(format);
238
264
  const channels = formatChannels(format, 0);
239
265
  const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
240
266
  const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1e3 : void 0;
241
- const codec = /mp3|mpeg/i.test(format) ? "mp3" : /opus/i.test(format) ? "opus" : /silk/i.test(format) ? "silk" : /pcm|mulaw|alaw|siren/i.test(format) ? "pcm" : "unknown";
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";
242
268
  const bitDepthMatch = /(\d+)bit/i.exec(format);
243
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;
244
270
  return {
@@ -251,7 +277,7 @@ function formatAudioSpecification(format) {
251
277
  ...bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {},
252
278
  ...container ? { container } : {},
253
279
  isVbr: /vbr/i.test(format),
254
- isCompressed: codec === "mp3" || codec === "opus" || codec === "silk"
280
+ isCompressed: codec !== "pcm" && codec !== "unknown"
255
281
  };
256
282
  }
257
283
  function parseMp3Specification(buffer, format) {
@@ -302,27 +328,43 @@ function inspectAudioSpecification(buffer, format) {
302
328
  const channels = view.getUint16(2, true);
303
329
  const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
304
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";
305
333
  return {
306
334
  format,
307
335
  mimeType: "audio/wav",
308
- codec: formatCode === 1 ? "pcm" : "unknown",
336
+ codec,
309
337
  sampleRate,
310
338
  channels,
311
339
  ...sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {},
312
340
  bitDepth: bitsPerSample,
313
341
  container: "riff-wave",
314
342
  isVbr: false,
315
- isCompressed: formatCode !== 1
343
+ isCompressed: codec !== "pcm" && codec !== "unknown"
316
344
  };
317
345
  }
318
346
  if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
319
- 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
+ }
320
362
  }
321
363
  function validateAudioSpecifications(specs) {
322
364
  const first = specs[0];
323
365
  if (!first) return;
324
366
  const mismatch = specs.find(
325
- (spec) => spec.sampleRate !== first.sampleRate || spec.channels !== first.channels || first.bitrate !== void 0 && spec.bitrate !== void 0 && spec.bitrate !== first.bitrate || first.bitDepth !== void 0 && spec.bitDepth !== void 0 && spec.bitDepth !== first.bitDepth || first.container !== void 0 && spec.container !== void 0 && spec.container !== first.container || first.isVbr !== void 0 && spec.isVbr !== void 0 && spec.isVbr !== first.isVbr
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
326
368
  );
327
369
  if (mismatch)
328
370
  throw new AudioFormatMismatchError(
@@ -405,6 +447,30 @@ function isWavFormat(format) {
405
447
  function isRawFormat(format) {
406
448
  return /^raw(?:-|$)/i.test(format);
407
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
+ }
408
474
  function resolveMergeAudioFormat(format) {
409
475
  if (isWavFormat(format)) return "wav";
410
476
  if (isMp3Format(format)) return "mp3";
@@ -457,7 +523,7 @@ function closeSpeechResources(speechConfig, synthesizer) {
457
523
  }
458
524
  }
459
525
  var ticksToMilliseconds = (ticks) => Math.max(0, ticks) / 1e4;
460
- async function synthesizeSsml(ssml, config) {
526
+ async function synthesizeSsmlOnce(ssml, config) {
461
527
  if (config.signal?.aborted) {
462
528
  throw new SynthesisCancelledError();
463
529
  }
@@ -587,6 +653,13 @@ async function synthesizeSsml(ssml, config) {
587
653
  rejectWithError(err);
588
654
  return;
589
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
+ }
590
663
  settled = true;
591
664
  cleanup();
592
665
  closeResources();
@@ -622,8 +695,8 @@ async function synthesizeSsml(ssml, config) {
622
695
  resolve({
623
696
  audioData: result.audioData,
624
697
  durationMs,
625
- audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
626
- mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
698
+ audioSpec,
699
+ mimeType: config.outputMimeType ?? resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
627
700
  ...config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {},
628
701
  ...requestId ? { requestId } : {},
629
702
  ...sourceBoundaries.length > 0 ? { boundaries: sourceBoundaries, wordBoundary: sourceBoundaries, wordBoundaries: sourceBoundaries } : {},
@@ -636,7 +709,7 @@ async function synthesizeSsml(ssml, config) {
636
709
  abortHandler = () => rejectWithError(new SynthesisCancelledError());
637
710
  config.signal.addEventListener("abort", abortHandler, { once: true });
638
711
  }
639
- const timeoutMs = config.timeouts?.perChunkMs ?? config.timeoutMs;
712
+ const timeoutMs = config.timeouts?.perChunkMs ?? config.timeouts?.totalJobMs ?? config.timeoutMs;
640
713
  if (timeoutMs !== void 0 && timeoutMs > 0) {
641
714
  timeout = setTimeout(
642
715
  () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
@@ -689,7 +762,7 @@ async function waitForRetry(delayMs, signal) {
689
762
  }
690
763
  });
691
764
  }
692
- async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
765
+ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry, deadlineAtMs) {
693
766
  const options = retryOptions ? {
694
767
  maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
695
768
  initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
@@ -700,17 +773,29 @@ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
700
773
  while (true) {
701
774
  if (config.signal?.aborted) throw new SynthesisCancelledError();
702
775
  try {
703
- return await synthesizeSsml(ssml, config);
776
+ return await synthesizeSsmlOnce(ssml, config);
704
777
  } catch (error) {
705
778
  if (!options || attempt >= options.maxRetries || !(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error)))
706
779
  throw error;
707
780
  attempt += 1;
708
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
+ }
709
788
  onRetry(attempt, delayMs);
710
- await waitForRetry(delayMs, config.signal);
789
+ await waitForRetry(Math.min(delayMs, remainingMs ?? delayMs), config.signal);
711
790
  }
712
791
  }
713
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
+ }
714
799
  function createAbortScope(parent, timeoutMs) {
715
800
  const controller = new AbortController();
716
801
  let didTimeout = false;
@@ -731,10 +816,10 @@ function createAbortScope(parent, timeoutMs) {
731
816
  abort: () => controller.abort()
732
817
  };
733
818
  }
734
- async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs, onRetry) {
819
+ async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs, onRetry, deadlineAtMs) {
735
820
  const scope = createAbortScope(config.signal, timeoutMs);
736
821
  try {
737
- return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry);
822
+ return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry, deadlineAtMs);
738
823
  } catch (error) {
739
824
  if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
740
825
  throw error;
@@ -743,18 +828,36 @@ async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs,
743
828
  }
744
829
  }
745
830
  async function synthesizeSsmlChunks(chunks, config) {
746
- const results = new Array(chunks.length);
747
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);
748
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
+ }));
749
844
  for (const [index, cached] of cachedChunks) {
750
- if (index >= 0 && index < totalChunks) results[index] = cached;
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
+ }
751
853
  }
752
854
  const requestedIndices = config.resumeChunkIndices ? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks)) : void 0;
753
- const shouldSynthesize = (index) => !cachedChunks.has(index) && (requestedIndices === void 0 || requestedIndices.has(index));
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;
754
858
  const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
755
859
  const report = (event) => config.onProgress?.(event);
756
- for (const [index, chunk] of chunks.entries()) {
757
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
860
+ for (const [index, input] of inputs.entries()) {
758
861
  report({
759
862
  currentChunk: index,
760
863
  totalChunks,
@@ -776,8 +879,7 @@ async function synthesizeSsmlChunks(chunks, config) {
776
879
  if (index >= chunks.length) return;
777
880
  if (!shouldSynthesize(index)) continue;
778
881
  if (firstError && config.cancelOnFailure !== false) return;
779
- const chunk = chunks[index];
780
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
882
+ const input = inputs[index];
781
883
  report({
782
884
  currentChunk: completed,
783
885
  totalChunks,
@@ -814,9 +916,11 @@ async function synthesizeSsmlChunks(chunks, config) {
814
916
  retryAttempt,
815
917
  nextRetryDelayMs,
816
918
  isRetrying: true
817
- })
919
+ }),
920
+ jobDeadlineAt
818
921
  );
819
922
  results[index] = result;
923
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result };
820
924
  completed += 1;
821
925
  report({
822
926
  currentChunk: completed,
@@ -828,7 +932,16 @@ async function synthesizeSsmlChunks(chunks, config) {
828
932
  durationMs: Date.now() - startedAt
829
933
  });
830
934
  } catch (error) {
831
- failedIndices.add(index);
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
+ };
832
945
  report({
833
946
  currentChunk: completed,
834
947
  totalChunks,
@@ -839,7 +952,6 @@ async function synthesizeSsmlChunks(chunks, config) {
839
952
  durationMs: Date.now() - startedAt,
840
953
  error
841
954
  });
842
- firstError ?? (firstError = scope.timedOut() ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`) : error);
843
955
  if (config.cancelOnFailure !== false) scope.abort();
844
956
  return;
845
957
  }
@@ -857,11 +969,25 @@ async function synthesizeSsmlChunks(chunks, config) {
857
969
  postMergeValidator: config.postMergeValidator
858
970
  });
859
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
+ );
860
982
  const partial = {
861
- synthesizedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
862
- completedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
863
- pendingChunkIndices: chunks.flatMap((_chunk, chunkIndex) => results[chunkIndex] ? [] : [chunkIndex]),
983
+ synthesizedChunks,
984
+ completedChunks: synthesizedChunks,
985
+ pendingChunkIndices: chunkStates.flatMap(
986
+ (state) => state.status === "pending" || state.status === "cancelled" || state.status === "failed" ? [state.chunkIndex] : []
987
+ ),
864
988
  failedChunkIndices: [...failedIndices],
989
+ cancelledChunkIndices: chunkStates.filter((state) => state.status === "cancelled").map((state) => state.chunkIndex),
990
+ chunkStates,
865
991
  totalChunks
866
992
  };
867
993
  if (error && typeof error === "object") error.partialResult = partial;
@@ -959,16 +1085,15 @@ function mergeSynthesisResults(results, options) {
959
1085
  })
960
1086
  ).then((merged) => {
961
1087
  if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
962
- if (!(merged instanceof ArrayBuffer) || buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
963
- throw new MergeError("The custom audio merger returned an invalid audio buffer.");
964
1088
  if (signal.aborted) throw new SynthesisCancelledError();
965
- const result = createMergedResult(
966
- results,
1089
+ const mergedSpec = validateMergedAudioBuffer(
967
1090
  merged,
968
1091
  format,
969
- inspectAudioSpecification(merged, format),
970
- resolvedOptions.outputMimeType
1092
+ buffers,
1093
+ inputSpecs,
1094
+ resolvedOptions.outputMimeType ?? resolveMimeType(format)
971
1095
  );
1096
+ const result = createMergedResult(results, merged, format, mergedSpec, resolvedOptions.outputMimeType);
972
1097
  return Promise.resolve(
973
1098
  resolvedOptions.postMergeValidator?.(result, {
974
1099
  format,
@@ -1101,7 +1226,7 @@ function resolveConcurrency2(value, total) {
1101
1226
  if (value === Infinity) return Math.max(1, total);
1102
1227
  return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
1103
1228
  }
1104
- async function retryableSynthesis(synthesize, options, signal, onRetry) {
1229
+ async function retryableSynthesis(synthesize, options, signal, onRetry, deadlineAtMs) {
1105
1230
  const retry = options ? {
1106
1231
  maxRetries: Math.max(0, Math.floor(options.maxRetries)),
1107
1232
  initialDelayMs: options.initialDelayMs,
@@ -1118,6 +1243,11 @@ async function retryableSynthesis(synthesize, options, signal, onRetry) {
1118
1243
  throw error;
1119
1244
  attempt += 1;
1120
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
+ }
1121
1251
  onRetry(attempt, delayMs);
1122
1252
  if (delayMs > 0)
1123
1253
  await new Promise((resolve, reject) => {
@@ -1165,20 +1295,24 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
1165
1295
  diagnostics: errors
1166
1296
  });
1167
1297
  }
1298
+ const jobScope = options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts.totalJobMs) : void 0;
1168
1299
  try {
1169
1300
  return {
1170
1301
  ok: true,
1171
1302
  success: true,
1172
1303
  status: "success",
1173
1304
  value: await client.synthesizeSsml(ssml, {
1174
- signal: options.signal,
1305
+ signal: jobScope?.signal ?? options.signal,
1175
1306
  timeoutMs: options.timeouts?.perChunkMs,
1176
1307
  timeouts: options.timeouts
1177
1308
  })
1178
1309
  };
1179
1310
  } catch (error) {
1311
+ if (jobScope?.timedOut()) return failure(toSynthesisError(new Error("Speech synthesis timed out.")));
1180
1312
  const synthesisError = toSynthesisError(error);
1181
1313
  return failure(synthesisError);
1314
+ } finally {
1315
+ jobScope?.dispose();
1182
1316
  }
1183
1317
  }
1184
1318
  async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
@@ -1250,17 +1384,32 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1250
1384
  resumeChunkIndices: options.resumeChunkIndices,
1251
1385
  customMerger: options.customMerger,
1252
1386
  outputMimeType: options.outputMimeType,
1253
- postMergeValidator: options.postMergeValidator
1387
+ postMergeValidator: options.postMergeValidator,
1388
+ resumeValidation: options.resumeValidation
1254
1389
  });
1255
1390
  return { ok: true, success: true, status: "success", value };
1256
1391
  }
1392
+ const inputs = chunks.map((chunk) => typeof chunk === "string" ? { ssml: chunk } : chunk);
1393
+ const fingerprints = inputs.map((chunk) => computeChunkFingerprint(chunk.ssml, options.outputFormat));
1257
1394
  const results = new Array(chunks.length);
1395
+ const chunkStates = inputs.map((_chunk, chunkIndex) => ({
1396
+ chunkIndex,
1397
+ status: "pending",
1398
+ canResume: true
1399
+ }));
1258
1400
  const cachedChunks = new Map((options.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
1401
+ const invalidCachedIndices = /* @__PURE__ */ new Set();
1259
1402
  for (const [index, cached] of cachedChunks) {
1260
- if (index >= 0 && index < chunks.length) results[index] = cached;
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);
1261
1408
  }
1262
1409
  const requestedIndices = options.resumeChunkIndices ? new Set(options.resumeChunkIndices.filter((index) => index >= 0 && index < chunks.length)) : void 0;
1263
- const shouldSynthesize = (index) => !cachedChunks.has(index) && (requestedIndices === void 0 || requestedIndices.has(index));
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;
1264
1413
  const jobScope = chunks.length > 1 || options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs) : void 0;
1265
1414
  fallbackJobScope = jobScope;
1266
1415
  const failedIndices = /* @__PURE__ */ new Set();
@@ -1273,7 +1422,10 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1273
1422
  const index = nextIndex++;
1274
1423
  if (index >= chunks.length) return;
1275
1424
  if (!shouldSynthesize(index)) continue;
1276
- if (failedIndices.size > 0 && options.cancelOnFailure !== false) return;
1425
+ if (firstError && options.cancelOnFailure !== false) {
1426
+ chunkStates[index] = { chunkIndex: index, status: "cancelled", isOriginalFailure: false, canResume: true };
1427
+ return;
1428
+ }
1277
1429
  const chunk = chunks[index];
1278
1430
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
1279
1431
  const sourceNodePath = input.sourceNodePath;
@@ -1306,7 +1458,8 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1306
1458
  retryAttempt,
1307
1459
  nextRetryDelayMs,
1308
1460
  isRetrying: true
1309
- })
1461
+ }),
1462
+ jobDeadlineAt
1310
1463
  );
1311
1464
  } catch (error) {
1312
1465
  if (chunkScope?.timedOut())
@@ -1358,6 +1511,7 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1358
1511
  }))
1359
1512
  } : {}
1360
1513
  };
1514
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
1361
1515
  completed += 1;
1362
1516
  options.onProgress?.({
1363
1517
  currentChunk: completed,
@@ -1369,7 +1523,16 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1369
1523
  durationMs: Date.now() - startedAt
1370
1524
  });
1371
1525
  } catch (error) {
1372
- failedIndices.add(index);
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
+ };
1373
1536
  options.onProgress?.({
1374
1537
  currentChunk: completed,
1375
1538
  totalChunks: chunks.length,
@@ -1381,19 +1544,32 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1381
1544
  error
1382
1545
  });
1383
1546
  if (options.cancelOnFailure !== false) jobScope?.abort();
1384
- firstError ?? (firstError = error);
1385
1547
  return;
1386
1548
  }
1387
1549
  }
1388
1550
  };
1389
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
+ }
1390
1559
  if (failedIndices.size > 0) {
1391
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
+ );
1392
1564
  error.partialResult = {
1393
- synthesizedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
1394
- completedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
1395
- pendingChunkIndices: chunks.flatMap((_chunk, chunkIndex) => results[chunkIndex] ? [] : [chunkIndex]),
1565
+ synthesizedChunks,
1566
+ completedChunks: synthesizedChunks,
1567
+ pendingChunkIndices: chunkStates.flatMap(
1568
+ (state) => state.status === "pending" || state.status === "cancelled" || state.status === "failed" ? [state.chunkIndex] : []
1569
+ ),
1396
1570
  failedChunkIndices: [...failedIndices],
1571
+ cancelledChunkIndices: chunkStates.filter((state) => state.status === "cancelled").map((state) => state.chunkIndex),
1572
+ chunkStates,
1397
1573
  totalChunks: chunks.length
1398
1574
  };
1399
1575
  throw error;
@@ -1439,7 +1615,16 @@ var AzureTtsClient = class {
1439
1615
  const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
1440
1616
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
1441
1617
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
1442
- const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts };
1618
+ const config = {
1619
+ endpoint,
1620
+ region,
1621
+ subscriptionKey,
1622
+ outputFormat,
1623
+ signal,
1624
+ timeoutMs,
1625
+ timeouts,
1626
+ retryOptions: __privateGet(this, _options).retryOptions
1627
+ };
1443
1628
  return synthesizeSpeech(ssml, config);
1444
1629
  }
1445
1630
  async synthesizeSsml(ssml, options = {}) {
@@ -1456,7 +1641,13 @@ var AzureTtsClient = class {
1456
1641
  timeouts: options.timeouts ?? timeouts,
1457
1642
  sourceNodePath: options.sourceNodePath,
1458
1643
  sourceTextSegments: options.sourceTextSegments,
1459
- 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
1460
1651
  });
1461
1652
  }
1462
1653
  async synthesizeChunks(chunks, options = {}) {
@@ -1474,12 +1665,13 @@ var AzureTtsClient = class {
1474
1665
  onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1475
1666
  concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1476
1667
  retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
1477
- cancelOnFailure: options.cancelOnFailure,
1668
+ cancelOnFailure: options.cancelOnFailure ?? __privateGet(this, _options).cancelOnFailure,
1478
1669
  resumeChunks: options.resumeChunks,
1479
1670
  resumeChunkIndices: options.resumeChunkIndices,
1480
- customMerger: options.customMerger,
1481
- outputMimeType: options.outputMimeType,
1482
- postMergeValidator: options.postMergeValidator
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
1483
1675
  });
1484
1676
  }
1485
1677
  async synthesizeSsmlSafe(ssml, options = {}) {
@@ -1604,6 +1796,7 @@ export {
1604
1796
  SynthesisTimeoutError,
1605
1797
  UnsupportedMergeFormatError,
1606
1798
  canMergeAudioFormat,
1799
+ computeChunkFingerprint,
1607
1800
  fetchAzureVoiceCatalog,
1608
1801
  getRetryAfterDelayMs,
1609
1802
  inspectAudioSpecification,