ssml-builder-js 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
@@ -180,6 +180,9 @@ var OUTPUT_FORMATS = {
180
180
  function resolveMimeType(outputFormat) {
181
181
  if (/(?:wav|wave|riff)/i.test(outputFormat)) return "audio/wav";
182
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";
183
186
  if (/ogg/i.test(outputFormat)) return "audio/ogg";
184
187
  if (/webm/i.test(outputFormat)) return "audio/webm";
185
188
  if (/raw/i.test(outputFormat)) return "audio/L16";
@@ -208,6 +211,27 @@ function createSpeechConfig(config) {
208
211
  }
209
212
 
210
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
+ }
211
235
  function ascii(bytes, offset, value) {
212
236
  return [...value].every((character, index) => bytes[offset + index] === character.charCodeAt(0));
213
237
  }
@@ -247,9 +271,11 @@ function parseWav(buffer) {
247
271
  }
248
272
  return { chunks, data, format };
249
273
  }
250
- function formatNumber(format, pattern, fallback) {
251
- const match = pattern.exec(format);
252
- 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;
253
279
  }
254
280
  function formatChannels(format, fallback) {
255
281
  if (/stereo|2ch|dual/i.test(format)) return 2;
@@ -257,11 +283,11 @@ function formatChannels(format, fallback) {
257
283
  return fallback;
258
284
  }
259
285
  function formatAudioSpecification(format) {
260
- const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
286
+ const sampleRate = formatSampleRate(format);
261
287
  const channels = formatChannels(format, 0);
262
288
  const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
263
289
  const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1e3 : void 0;
264
- 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";
265
291
  const bitDepthMatch = /(\d+)bit/i.exec(format);
266
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;
267
293
  return {
@@ -274,7 +300,7 @@ function formatAudioSpecification(format) {
274
300
  ...bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {},
275
301
  ...container ? { container } : {},
276
302
  isVbr: /vbr/i.test(format),
277
- isCompressed: codec === "mp3" || codec === "opus" || codec === "silk"
303
+ isCompressed: codec !== "pcm" && codec !== "unknown"
278
304
  };
279
305
  }
280
306
  function parseMp3Specification(buffer, format) {
@@ -325,27 +351,43 @@ function inspectAudioSpecification(buffer, format) {
325
351
  const channels = view.getUint16(2, true);
326
352
  const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
327
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";
328
356
  return {
329
357
  format,
330
358
  mimeType: "audio/wav",
331
- codec: formatCode === 1 ? "pcm" : "unknown",
359
+ codec,
332
360
  sampleRate,
333
361
  channels,
334
362
  ...sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {},
335
363
  bitDepth: bitsPerSample,
336
364
  container: "riff-wave",
337
365
  isVbr: false,
338
- isCompressed: formatCode !== 1
366
+ isCompressed: codec !== "pcm" && codec !== "unknown"
339
367
  };
340
368
  }
341
369
  if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
342
- 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
+ }
343
385
  }
344
386
  function validateAudioSpecifications(specs) {
345
387
  const first = specs[0];
346
388
  if (!first) return;
347
389
  const mismatch = specs.find(
348
- (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
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
349
391
  );
350
392
  if (mismatch)
351
393
  throw new AudioFormatMismatchError(
@@ -428,6 +470,30 @@ function isWavFormat(format) {
428
470
  function isRawFormat(format) {
429
471
  return /^raw(?:-|$)/i.test(format);
430
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
+ }
431
497
  function resolveMergeAudioFormat(format) {
432
498
  if (isWavFormat(format)) return "wav";
433
499
  if (isMp3Format(format)) return "mp3";
@@ -480,7 +546,7 @@ function closeSpeechResources(speechConfig, synthesizer) {
480
546
  }
481
547
  }
482
548
  var ticksToMilliseconds = (ticks) => Math.max(0, ticks) / 1e4;
483
- async function synthesizeSsml(ssml, config) {
549
+ async function synthesizeSsmlOnce(ssml, config) {
484
550
  if (config.signal?.aborted) {
485
551
  throw new SynthesisCancelledError();
486
552
  }
@@ -610,6 +676,13 @@ async function synthesizeSsml(ssml, config) {
610
676
  rejectWithError(err);
611
677
  return;
612
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
+ }
613
686
  settled = true;
614
687
  cleanup();
615
688
  closeResources();
@@ -645,8 +718,8 @@ async function synthesizeSsml(ssml, config) {
645
718
  resolve({
646
719
  audioData: result.audioData,
647
720
  durationMs,
648
- audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
649
- mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
721
+ audioSpec,
722
+ mimeType: config.outputMimeType ?? resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
650
723
  ...config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {},
651
724
  ...requestId ? { requestId } : {},
652
725
  ...sourceBoundaries.length > 0 ? { boundaries: sourceBoundaries, wordBoundary: sourceBoundaries, wordBoundaries: sourceBoundaries } : {},
@@ -659,7 +732,7 @@ async function synthesizeSsml(ssml, config) {
659
732
  abortHandler = () => rejectWithError(new SynthesisCancelledError());
660
733
  config.signal.addEventListener("abort", abortHandler, { once: true });
661
734
  }
662
- const timeoutMs = config.timeouts?.perChunkMs ?? config.timeoutMs;
735
+ const timeoutMs = config.timeouts?.perChunkMs ?? config.timeouts?.totalJobMs ?? config.timeoutMs;
663
736
  if (timeoutMs !== void 0 && timeoutMs > 0) {
664
737
  timeout = setTimeout(
665
738
  () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
@@ -712,7 +785,7 @@ async function waitForRetry(delayMs, signal) {
712
785
  }
713
786
  });
714
787
  }
715
- async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
788
+ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry, deadlineAtMs) {
716
789
  const options = retryOptions ? {
717
790
  maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
718
791
  initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
@@ -723,17 +796,29 @@ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
723
796
  while (true) {
724
797
  if (config.signal?.aborted) throw new SynthesisCancelledError();
725
798
  try {
726
- return await synthesizeSsml(ssml, config);
799
+ return await synthesizeSsmlOnce(ssml, config);
727
800
  } catch (error) {
728
801
  if (!options || attempt >= options.maxRetries || !(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error)))
729
802
  throw error;
730
803
  attempt += 1;
731
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
+ }
732
811
  onRetry(attempt, delayMs);
733
- await waitForRetry(delayMs, config.signal);
812
+ await waitForRetry(Math.min(delayMs, remainingMs ?? delayMs), config.signal);
734
813
  }
735
814
  }
736
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
+ }
737
822
  function createAbortScope(parent, timeoutMs) {
738
823
  const controller = new AbortController();
739
824
  let didTimeout = false;
@@ -754,10 +839,10 @@ function createAbortScope(parent, timeoutMs) {
754
839
  abort: () => controller.abort()
755
840
  };
756
841
  }
757
- async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs, onRetry) {
842
+ async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs, onRetry, deadlineAtMs) {
758
843
  const scope = createAbortScope(config.signal, timeoutMs);
759
844
  try {
760
- return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry);
845
+ return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry, deadlineAtMs);
761
846
  } catch (error) {
762
847
  if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
763
848
  throw error;
@@ -766,18 +851,36 @@ async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs,
766
851
  }
767
852
  }
768
853
  async function synthesizeSsmlChunks(chunks, config) {
769
- const results = new Array(chunks.length);
770
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);
771
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
+ }));
772
867
  for (const [index, cached] of cachedChunks) {
773
- if (index >= 0 && index < totalChunks) results[index] = cached;
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
+ }
774
876
  }
775
877
  const requestedIndices = config.resumeChunkIndices ? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks)) : void 0;
776
- const shouldSynthesize = (index) => !cachedChunks.has(index) && (requestedIndices === void 0 || requestedIndices.has(index));
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;
777
881
  const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
778
882
  const report = (event) => config.onProgress?.(event);
779
- for (const [index, chunk] of chunks.entries()) {
780
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
883
+ for (const [index, input] of inputs.entries()) {
781
884
  report({
782
885
  currentChunk: index,
783
886
  totalChunks,
@@ -799,8 +902,7 @@ async function synthesizeSsmlChunks(chunks, config) {
799
902
  if (index >= chunks.length) return;
800
903
  if (!shouldSynthesize(index)) continue;
801
904
  if (firstError && config.cancelOnFailure !== false) return;
802
- const chunk = chunks[index];
803
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
905
+ const input = inputs[index];
804
906
  report({
805
907
  currentChunk: completed,
806
908
  totalChunks,
@@ -837,9 +939,11 @@ async function synthesizeSsmlChunks(chunks, config) {
837
939
  retryAttempt,
838
940
  nextRetryDelayMs,
839
941
  isRetrying: true
840
- })
942
+ }),
943
+ jobDeadlineAt
841
944
  );
842
945
  results[index] = result;
946
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result };
843
947
  completed += 1;
844
948
  report({
845
949
  currentChunk: completed,
@@ -851,7 +955,16 @@ async function synthesizeSsmlChunks(chunks, config) {
851
955
  durationMs: Date.now() - startedAt
852
956
  });
853
957
  } catch (error) {
854
- failedIndices.add(index);
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
+ };
855
968
  report({
856
969
  currentChunk: completed,
857
970
  totalChunks,
@@ -862,7 +975,6 @@ async function synthesizeSsmlChunks(chunks, config) {
862
975
  durationMs: Date.now() - startedAt,
863
976
  error
864
977
  });
865
- firstError ?? (firstError = scope.timedOut() ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`) : error);
866
978
  if (config.cancelOnFailure !== false) scope.abort();
867
979
  return;
868
980
  }
@@ -880,11 +992,25 @@ async function synthesizeSsmlChunks(chunks, config) {
880
992
  postMergeValidator: config.postMergeValidator
881
993
  });
882
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
+ );
883
1005
  const partial = {
884
- synthesizedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
885
- completedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
886
- pendingChunkIndices: chunks.flatMap((_chunk, chunkIndex) => results[chunkIndex] ? [] : [chunkIndex]),
1006
+ synthesizedChunks,
1007
+ completedChunks: synthesizedChunks,
1008
+ pendingChunkIndices: chunkStates.flatMap(
1009
+ (state) => state.status === "pending" || state.status === "cancelled" || state.status === "failed" ? [state.chunkIndex] : []
1010
+ ),
887
1011
  failedChunkIndices: [...failedIndices],
1012
+ cancelledChunkIndices: chunkStates.filter((state) => state.status === "cancelled").map((state) => state.chunkIndex),
1013
+ chunkStates,
888
1014
  totalChunks
889
1015
  };
890
1016
  if (error && typeof error === "object") error.partialResult = partial;
@@ -982,16 +1108,15 @@ function mergeSynthesisResults(results, options) {
982
1108
  })
983
1109
  ).then((merged) => {
984
1110
  if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
985
- if (!(merged instanceof ArrayBuffer) || buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
986
- throw new MergeError("The custom audio merger returned an invalid audio buffer.");
987
1111
  if (signal.aborted) throw new SynthesisCancelledError();
988
- const result = createMergedResult(
989
- results,
1112
+ const mergedSpec = validateMergedAudioBuffer(
990
1113
  merged,
991
1114
  format,
992
- inspectAudioSpecification(merged, format),
993
- resolvedOptions.outputMimeType
1115
+ buffers,
1116
+ inputSpecs,
1117
+ resolvedOptions.outputMimeType ?? resolveMimeType(format)
994
1118
  );
1119
+ const result = createMergedResult(results, merged, format, mergedSpec, resolvedOptions.outputMimeType);
995
1120
  return Promise.resolve(
996
1121
  resolvedOptions.postMergeValidator?.(result, {
997
1122
  format,
@@ -1120,7 +1245,7 @@ function resolveConcurrency2(value, total) {
1120
1245
  if (value === Infinity) return Math.max(1, total);
1121
1246
  return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
1122
1247
  }
1123
- async function retryableSynthesis(synthesize, options, signal, onRetry) {
1248
+ async function retryableSynthesis(synthesize, options, signal, onRetry, deadlineAtMs) {
1124
1249
  const retry = options ? {
1125
1250
  maxRetries: Math.max(0, Math.floor(options.maxRetries)),
1126
1251
  initialDelayMs: options.initialDelayMs,
@@ -1137,6 +1262,11 @@ async function retryableSynthesis(synthesize, options, signal, onRetry) {
1137
1262
  throw error;
1138
1263
  attempt += 1;
1139
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
+ }
1140
1270
  onRetry(attempt, delayMs);
1141
1271
  if (delayMs > 0)
1142
1272
  await new Promise((resolve, reject) => {
@@ -1184,20 +1314,24 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
1184
1314
  diagnostics: errors
1185
1315
  });
1186
1316
  }
1317
+ const jobScope = options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts.totalJobMs) : void 0;
1187
1318
  try {
1188
1319
  return {
1189
1320
  ok: true,
1190
1321
  success: true,
1191
1322
  status: "success",
1192
1323
  value: await client.synthesizeSsml(ssml, {
1193
- signal: options.signal,
1324
+ signal: jobScope?.signal ?? options.signal,
1194
1325
  timeoutMs: options.timeouts?.perChunkMs,
1195
1326
  timeouts: options.timeouts
1196
1327
  })
1197
1328
  };
1198
1329
  } catch (error) {
1330
+ if (jobScope?.timedOut()) return failure(toSynthesisError(new Error("Speech synthesis timed out.")));
1199
1331
  const synthesisError = toSynthesisError(error);
1200
1332
  return failure(synthesisError);
1333
+ } finally {
1334
+ jobScope?.dispose();
1201
1335
  }
1202
1336
  }
1203
1337
  async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
@@ -1269,17 +1403,32 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1269
1403
  resumeChunkIndices: options.resumeChunkIndices,
1270
1404
  customMerger: options.customMerger,
1271
1405
  outputMimeType: options.outputMimeType,
1272
- postMergeValidator: options.postMergeValidator
1406
+ postMergeValidator: options.postMergeValidator,
1407
+ resumeValidation: options.resumeValidation
1273
1408
  });
1274
1409
  return { ok: true, success: true, status: "success", value };
1275
1410
  }
1411
+ const inputs = chunks.map((chunk) => typeof chunk === "string" ? { ssml: chunk } : chunk);
1412
+ const fingerprints = inputs.map((chunk) => computeChunkFingerprint(chunk.ssml, options.outputFormat));
1276
1413
  const results = new Array(chunks.length);
1414
+ const chunkStates = inputs.map((_chunk, chunkIndex) => ({
1415
+ chunkIndex,
1416
+ status: "pending",
1417
+ canResume: true
1418
+ }));
1277
1419
  const cachedChunks = new Map((options.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
1420
+ const invalidCachedIndices = /* @__PURE__ */ new Set();
1278
1421
  for (const [index, cached] of cachedChunks) {
1279
- if (index >= 0 && index < chunks.length) results[index] = cached;
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);
1280
1427
  }
1281
1428
  const requestedIndices = options.resumeChunkIndices ? new Set(options.resumeChunkIndices.filter((index) => index >= 0 && index < chunks.length)) : void 0;
1282
- const shouldSynthesize = (index) => !cachedChunks.has(index) && (requestedIndices === void 0 || requestedIndices.has(index));
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;
1283
1432
  const jobScope = chunks.length > 1 || options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs) : void 0;
1284
1433
  fallbackJobScope = jobScope;
1285
1434
  const failedIndices = /* @__PURE__ */ new Set();
@@ -1292,7 +1441,10 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1292
1441
  const index = nextIndex++;
1293
1442
  if (index >= chunks.length) return;
1294
1443
  if (!shouldSynthesize(index)) continue;
1295
- if (failedIndices.size > 0 && options.cancelOnFailure !== false) return;
1444
+ if (firstError && options.cancelOnFailure !== false) {
1445
+ chunkStates[index] = { chunkIndex: index, status: "cancelled", isOriginalFailure: false, canResume: true };
1446
+ return;
1447
+ }
1296
1448
  const chunk = chunks[index];
1297
1449
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
1298
1450
  const sourceNodePath = input.sourceNodePath;
@@ -1325,7 +1477,8 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1325
1477
  retryAttempt,
1326
1478
  nextRetryDelayMs,
1327
1479
  isRetrying: true
1328
- })
1480
+ }),
1481
+ jobDeadlineAt
1329
1482
  );
1330
1483
  } catch (error) {
1331
1484
  if (chunkScope?.timedOut())
@@ -1377,6 +1530,7 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1377
1530
  }))
1378
1531
  } : {}
1379
1532
  };
1533
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
1380
1534
  completed += 1;
1381
1535
  options.onProgress?.({
1382
1536
  currentChunk: completed,
@@ -1388,7 +1542,16 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1388
1542
  durationMs: Date.now() - startedAt
1389
1543
  });
1390
1544
  } catch (error) {
1391
- failedIndices.add(index);
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
+ };
1392
1555
  options.onProgress?.({
1393
1556
  currentChunk: completed,
1394
1557
  totalChunks: chunks.length,
@@ -1400,19 +1563,32 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1400
1563
  error
1401
1564
  });
1402
1565
  if (options.cancelOnFailure !== false) jobScope?.abort();
1403
- firstError ?? (firstError = error);
1404
1566
  return;
1405
1567
  }
1406
1568
  }
1407
1569
  };
1408
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
+ }
1409
1578
  if (failedIndices.size > 0) {
1410
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
+ );
1411
1583
  error.partialResult = {
1412
- synthesizedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
1413
- completedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
1414
- pendingChunkIndices: chunks.flatMap((_chunk, chunkIndex) => results[chunkIndex] ? [] : [chunkIndex]),
1584
+ synthesizedChunks,
1585
+ completedChunks: synthesizedChunks,
1586
+ pendingChunkIndices: chunkStates.flatMap(
1587
+ (state) => state.status === "pending" || state.status === "cancelled" || state.status === "failed" ? [state.chunkIndex] : []
1588
+ ),
1415
1589
  failedChunkIndices: [...failedIndices],
1590
+ cancelledChunkIndices: chunkStates.filter((state) => state.status === "cancelled").map((state) => state.chunkIndex),
1591
+ chunkStates,
1416
1592
  totalChunks: chunks.length
1417
1593
  };
1418
1594
  throw error;
@@ -1458,7 +1634,16 @@ var AzureTtsClient = class {
1458
1634
  const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
1459
1635
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
1460
1636
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
1461
- const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts };
1637
+ const config = {
1638
+ endpoint,
1639
+ region,
1640
+ subscriptionKey,
1641
+ outputFormat,
1642
+ signal,
1643
+ timeoutMs,
1644
+ timeouts,
1645
+ retryOptions: __privateGet(this, _options).retryOptions
1646
+ };
1462
1647
  return synthesizeSpeech(ssml, config);
1463
1648
  }
1464
1649
  async synthesizeSsml(ssml, options = {}) {
@@ -1475,7 +1660,13 @@ var AzureTtsClient = class {
1475
1660
  timeouts: options.timeouts ?? timeouts,
1476
1661
  sourceNodePath: options.sourceNodePath,
1477
1662
  sourceTextSegments: options.sourceTextSegments,
1478
- 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
1479
1670
  });
1480
1671
  }
1481
1672
  async synthesizeChunks(chunks, options = {}) {
@@ -1493,12 +1684,13 @@ var AzureTtsClient = class {
1493
1684
  onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1494
1685
  concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1495
1686
  retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
1496
- cancelOnFailure: options.cancelOnFailure,
1687
+ cancelOnFailure: options.cancelOnFailure ?? __privateGet(this, _options).cancelOnFailure,
1497
1688
  resumeChunks: options.resumeChunks,
1498
1689
  resumeChunkIndices: options.resumeChunkIndices,
1499
- customMerger: options.customMerger,
1500
- outputMimeType: options.outputMimeType,
1501
- postMergeValidator: options.postMergeValidator
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
1502
1694
  });
1503
1695
  }
1504
1696
  async synthesizeSsmlSafe(ssml, options = {}) {
@@ -1626,6 +1818,7 @@ export {
1626
1818
  buildPartialSsml,
1627
1819
  buildSsml,
1628
1820
  canMergeAudioFormat,
1821
+ computeChunkFingerprint,
1629
1822
  createAzureUrlValidatorRunner,
1630
1823
  extractSsmlText,
1631
1824
  extractSsmlTranslatableText,