@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.js CHANGED
@@ -49,6 +49,7 @@ __export(index_exports, {
49
49
  SynthesisTimeoutError: () => SynthesisTimeoutError,
50
50
  UnsupportedMergeFormatError: () => UnsupportedMergeFormatError,
51
51
  canMergeAudioFormat: () => canMergeAudioFormat,
52
+ computeChunkFingerprint: () => computeChunkFingerprint,
52
53
  fetchAzureVoiceCatalog: () => fetchAzureVoiceCatalog,
53
54
  getRetryAfterDelayMs: () => getRetryAfterDelayMs,
54
55
  inspectAudioSpecification: () => inspectAudioSpecification,
@@ -215,6 +216,9 @@ var OUTPUT_FORMATS = {
215
216
  function resolveMimeType(outputFormat) {
216
217
  if (/(?:wav|wave|riff)/i.test(outputFormat)) return "audio/wav";
217
218
  if (/(?:mp3|mpeg)/i.test(outputFormat)) return "audio/mpeg";
219
+ if (/mulaw|mu-law/i.test(outputFormat)) return "audio/basic";
220
+ if (/alaw|a-law/i.test(outputFormat)) return "audio/alaw";
221
+ if (/siren/i.test(outputFormat)) return "audio/siren";
218
222
  if (/ogg/i.test(outputFormat)) return "audio/ogg";
219
223
  if (/webm/i.test(outputFormat)) return "audio/webm";
220
224
  if (/raw/i.test(outputFormat)) return "audio/L16";
@@ -243,6 +247,27 @@ function createSpeechConfig(config) {
243
247
  }
244
248
 
245
249
  // src/synthesis.ts
250
+ function computeChunkFingerprint(ssml, outputFormat = DEFAULT_OUTPUT_FORMAT) {
251
+ const readAttribute = (name) => {
252
+ const pattern = new RegExp(`(?:${name})\\s*=\\s*[\\"']([^\\"']*)`, "gi");
253
+ return [...ssml.matchAll(pattern)].map((match) => match[1] ?? "").join("|");
254
+ };
255
+ const payload = JSON.stringify({
256
+ ssml,
257
+ outputFormat,
258
+ voice: readAttribute("(?:name|voice)"),
259
+ language: readAttribute("(?:xml:lang|lang)"),
260
+ rate: readAttribute("rate"),
261
+ pitch: readAttribute("pitch")
262
+ });
263
+ let hash = 0xcbf29ce484222325n;
264
+ const mask = 0xffffffffffffffffn;
265
+ for (let index = 0; index < payload.length; index += 1) {
266
+ hash ^= BigInt(payload.charCodeAt(index));
267
+ hash = hash * 0x100000001b3n & mask;
268
+ }
269
+ return `fnv1a64-${hash.toString(16).padStart(16, "0")}`;
270
+ }
246
271
  function ascii(bytes, offset, value) {
247
272
  return [...value].every((character, index) => bytes[offset + index] === character.charCodeAt(0));
248
273
  }
@@ -282,9 +307,11 @@ function parseWav(buffer) {
282
307
  }
283
308
  return { chunks, data, format };
284
309
  }
285
- function formatNumber(format, pattern, fallback) {
286
- const match = pattern.exec(format);
287
- return match?.[1] ? Number(match[1]) : fallback;
310
+ function formatSampleRate(format) {
311
+ const match = /(?:^|-)(\d+)(khz|hz)(?:-|$)/i.exec(format);
312
+ if (!match?.[1] || !match[2]) return 0;
313
+ const value = Number(match[1]);
314
+ return match[2].toLowerCase() === "khz" ? value * 1e3 : value;
288
315
  }
289
316
  function formatChannels(format, fallback) {
290
317
  if (/stereo|2ch|dual/i.test(format)) return 2;
@@ -292,11 +319,11 @@ function formatChannels(format, fallback) {
292
319
  return fallback;
293
320
  }
294
321
  function formatAudioSpecification(format) {
295
- const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
322
+ const sampleRate = formatSampleRate(format);
296
323
  const channels = formatChannels(format, 0);
297
324
  const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
298
325
  const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1e3 : void 0;
299
- const codec = /mp3|mpeg/i.test(format) ? "mp3" : /opus/i.test(format) ? "opus" : /silk/i.test(format) ? "silk" : /pcm|mulaw|alaw|siren/i.test(format) ? "pcm" : "unknown";
326
+ const codec = /mp3|mpeg/i.test(format) ? "mp3" : /opus/i.test(format) ? "opus" : /silk/i.test(format) ? "silk" : /mulaw|mu-law/i.test(format) ? "mulaw" : /alaw|a-law/i.test(format) ? "alaw" : /siren/i.test(format) ? "siren" : /pcm/i.test(format) ? "pcm" : "unknown";
300
327
  const bitDepthMatch = /(\d+)bit/i.exec(format);
301
328
  const container = /(?:wav|wave|riff)/i.test(format) ? "riff-wave" : /mp3|mpeg/i.test(format) ? "mp3-raw" : /ogg/i.test(format) ? "ogg" : /webm/i.test(format) ? "webm" : /raw/i.test(format) ? "raw" : void 0;
302
329
  return {
@@ -309,7 +336,7 @@ function formatAudioSpecification(format) {
309
336
  ...bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {},
310
337
  ...container ? { container } : {},
311
338
  isVbr: /vbr/i.test(format),
312
- isCompressed: codec === "mp3" || codec === "opus" || codec === "silk"
339
+ isCompressed: codec !== "pcm" && codec !== "unknown"
313
340
  };
314
341
  }
315
342
  function parseMp3Specification(buffer, format) {
@@ -360,27 +387,43 @@ function inspectAudioSpecification(buffer, format) {
360
387
  const channels = view.getUint16(2, true);
361
388
  const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
362
389
  const formatCode = view.getUint16(0, true);
390
+ const namedCodec = formatAudioSpecification(format).codec;
391
+ const codec = formatCode === 1 ? "pcm" : formatCode === 6 ? "alaw" : formatCode === 7 ? "mulaw" : namedCodec === "siren" ? "siren" : "unknown";
363
392
  return {
364
393
  format,
365
394
  mimeType: "audio/wav",
366
- codec: formatCode === 1 ? "pcm" : "unknown",
395
+ codec,
367
396
  sampleRate,
368
397
  channels,
369
398
  ...sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {},
370
399
  bitDepth: bitsPerSample,
371
400
  container: "riff-wave",
372
401
  isVbr: false,
373
- isCompressed: formatCode !== 1
402
+ isCompressed: codec !== "pcm" && codec !== "unknown"
374
403
  };
375
404
  }
376
405
  if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
377
- return formatAudioSpecification(format);
406
+ const specification = formatAudioSpecification(format);
407
+ if (specification.container === "raw") validateRawAudioBuffer(buffer, specification);
408
+ return specification;
409
+ }
410
+ function validateRawAudioBuffer(buffer, specification) {
411
+ if (specification.sampleRate <= 0 || specification.channels <= 0 || specification.bitDepth === void 0) {
412
+ throw new Error(`RAW audio format "${specification.format}" does not define a complete audio specification.`);
413
+ }
414
+ if (specification.codec === "siren" || specification.codec === "silk") return;
415
+ const bytesPerFrame = specification.channels * Math.ceil(specification.bitDepth / 8);
416
+ if (bytesPerFrame <= 0 || buffer.byteLength % bytesPerFrame !== 0) {
417
+ throw new Error(
418
+ `RAW audio buffer size ${buffer.byteLength} is not aligned to ${bytesPerFrame}-byte audio frames for "${specification.format}".`
419
+ );
420
+ }
378
421
  }
379
422
  function validateAudioSpecifications(specs) {
380
423
  const first = specs[0];
381
424
  if (!first) return;
382
425
  const mismatch = specs.find(
383
- (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
426
+ (spec) => spec.sampleRate !== first.sampleRate || spec.channels !== first.channels || spec.codec !== first.codec || first.bitrate !== void 0 && spec.bitrate !== void 0 && spec.bitrate !== first.bitrate || first.bitDepth !== void 0 && spec.bitDepth !== void 0 && spec.bitDepth !== first.bitDepth || first.container !== void 0 && spec.container !== void 0 && spec.container !== first.container || first.isVbr !== void 0 && spec.isVbr !== void 0 && spec.isVbr !== first.isVbr
384
427
  );
385
428
  if (mismatch)
386
429
  throw new AudioFormatMismatchError(
@@ -463,6 +506,30 @@ function isWavFormat(format) {
463
506
  function isRawFormat(format) {
464
507
  return /^raw(?:-|$)/i.test(format);
465
508
  }
509
+ function validateMergedAudioBuffer(merged, format, buffers, inputSpecs, outputMimeType) {
510
+ if (!(merged instanceof ArrayBuffer) || merged.byteLength === 0) {
511
+ throw new MergeError("The custom audio merger returned an empty or invalid audio buffer.");
512
+ }
513
+ const specification = inspectAudioSpecification(merged, format);
514
+ if (!outputMimeType.trim()) throw new MergeError("The custom audio merger output MIME type cannot be empty.");
515
+ const firstInput = inputSpecs[0];
516
+ if (firstInput && (specification.sampleRate !== firstInput.sampleRate || specification.channels !== firstInput.channels || specification.codec !== firstInput.codec || firstInput.bitDepth !== void 0 && specification.bitDepth !== firstInput.bitDepth)) {
517
+ throw new AudioFormatMismatchError("The custom audio merger returned an incompatible audio stream.", [
518
+ ...inputSpecs,
519
+ specification
520
+ ]);
521
+ }
522
+ if (isRawFormat(format)) {
523
+ const expectedSize = buffers.reduce((total, input) => total + input.byteLength, 0);
524
+ if (merged.byteLength !== expectedSize) {
525
+ throw new MergeError(
526
+ `The custom audio merger returned ${merged.byteLength} bytes; ${expectedSize} were expected.`
527
+ );
528
+ }
529
+ validateRawAudioBuffer(merged, specification);
530
+ }
531
+ return specification;
532
+ }
466
533
  function resolveMergeAudioFormat(format) {
467
534
  if (isWavFormat(format)) return "wav";
468
535
  if (isMp3Format(format)) return "mp3";
@@ -515,7 +582,7 @@ function closeSpeechResources(speechConfig, synthesizer) {
515
582
  }
516
583
  }
517
584
  var ticksToMilliseconds = (ticks) => Math.max(0, ticks) / 1e4;
518
- async function synthesizeSsml(ssml, config) {
585
+ async function synthesizeSsmlOnce(ssml, config) {
519
586
  if (config.signal?.aborted) {
520
587
  throw new SynthesisCancelledError();
521
588
  }
@@ -645,6 +712,13 @@ async function synthesizeSsml(ssml, config) {
645
712
  rejectWithError(err);
646
713
  return;
647
714
  }
715
+ let audioSpec;
716
+ try {
717
+ audioSpec = inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT);
718
+ } catch (error) {
719
+ rejectWithError(error);
720
+ return;
721
+ }
648
722
  settled = true;
649
723
  cleanup();
650
724
  closeResources();
@@ -680,8 +754,8 @@ async function synthesizeSsml(ssml, config) {
680
754
  resolve({
681
755
  audioData: result.audioData,
682
756
  durationMs,
683
- audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
684
- mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
757
+ audioSpec,
758
+ mimeType: config.outputMimeType ?? resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
685
759
  ...config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {},
686
760
  ...requestId ? { requestId } : {},
687
761
  ...sourceBoundaries.length > 0 ? { boundaries: sourceBoundaries, wordBoundary: sourceBoundaries, wordBoundaries: sourceBoundaries } : {},
@@ -694,7 +768,7 @@ async function synthesizeSsml(ssml, config) {
694
768
  abortHandler = () => rejectWithError(new SynthesisCancelledError());
695
769
  config.signal.addEventListener("abort", abortHandler, { once: true });
696
770
  }
697
- const timeoutMs = config.timeouts?.perChunkMs ?? config.timeoutMs;
771
+ const timeoutMs = config.timeouts?.perChunkMs ?? config.timeouts?.totalJobMs ?? config.timeoutMs;
698
772
  if (timeoutMs !== void 0 && timeoutMs > 0) {
699
773
  timeout = setTimeout(
700
774
  () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
@@ -747,7 +821,7 @@ async function waitForRetry(delayMs, signal) {
747
821
  }
748
822
  });
749
823
  }
750
- async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
824
+ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry, deadlineAtMs) {
751
825
  const options = retryOptions ? {
752
826
  maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
753
827
  initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
@@ -758,17 +832,29 @@ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
758
832
  while (true) {
759
833
  if (config.signal?.aborted) throw new SynthesisCancelledError();
760
834
  try {
761
- return await synthesizeSsml(ssml, config);
835
+ return await synthesizeSsmlOnce(ssml, config);
762
836
  } catch (error) {
763
837
  if (!options || attempt >= options.maxRetries || !(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error)))
764
838
  throw error;
765
839
  attempt += 1;
766
840
  const delayMs = retryDelay(options, attempt, error);
841
+ const remainingMs = deadlineAtMs === void 0 ? void 0 : Math.max(0, deadlineAtMs - Date.now());
842
+ if (getRetryAfterDelayMs(error) !== void 0 && (delayMs > options.maxDelayMs || remainingMs !== void 0 && delayMs > remainingMs)) {
843
+ throw new SynthesisTimeoutError(
844
+ remainingMs === void 0 ? `Retry-After exceeded maxDelayMs (${options.maxDelayMs} ms).` : `Retry-After exceeded the remaining total job timeout (${remainingMs} ms).`
845
+ );
846
+ }
767
847
  onRetry(attempt, delayMs);
768
- await waitForRetry(delayMs, config.signal);
848
+ await waitForRetry(Math.min(delayMs, remainingMs ?? delayMs), config.signal);
769
849
  }
770
850
  }
771
851
  }
852
+ async function synthesizeSsml(ssml, config) {
853
+ const totalJobMs = config.timeouts?.totalJobMs;
854
+ const deadlineAtMs = totalJobMs !== void 0 && totalJobMs > 0 ? Date.now() + totalJobMs : void 0;
855
+ if (!config.retryOptions) return synthesizeSsmlOnce(ssml, config);
856
+ return synthesizeWithRetry(ssml, config, config.retryOptions, () => void 0, deadlineAtMs);
857
+ }
772
858
  function createAbortScope(parent, timeoutMs) {
773
859
  const controller = new AbortController();
774
860
  let didTimeout = false;
@@ -789,10 +875,10 @@ function createAbortScope(parent, timeoutMs) {
789
875
  abort: () => controller.abort()
790
876
  };
791
877
  }
792
- async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs, onRetry) {
878
+ async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs, onRetry, deadlineAtMs) {
793
879
  const scope = createAbortScope(config.signal, timeoutMs);
794
880
  try {
795
- return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry);
881
+ return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry, deadlineAtMs);
796
882
  } catch (error) {
797
883
  if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
798
884
  throw error;
@@ -801,18 +887,36 @@ async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs,
801
887
  }
802
888
  }
803
889
  async function synthesizeSsmlChunks(chunks, config) {
804
- const results = new Array(chunks.length);
805
890
  const totalChunks = chunks.length;
891
+ const inputs = chunks.map((chunk) => typeof chunk === "string" ? { ssml: chunk } : chunk);
892
+ const fingerprints = inputs.map(
893
+ (chunk) => computeChunkFingerprint(chunk.ssml, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT)
894
+ );
895
+ const results = new Array(totalChunks);
806
896
  const cachedChunks = new Map((config.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
897
+ const invalidCachedIndices = /* @__PURE__ */ new Set();
898
+ const chunkStates = inputs.map((_chunk, chunkIndex) => ({
899
+ chunkIndex,
900
+ status: "pending",
901
+ canResume: true
902
+ }));
807
903
  for (const [index, cached] of cachedChunks) {
808
- if (index >= 0 && index < totalChunks) results[index] = cached;
904
+ if (index < 0 || index >= totalChunks) continue;
905
+ const isValid = config.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index];
906
+ if (isValid) {
907
+ results[index] = { ...cached };
908
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
909
+ } else {
910
+ invalidCachedIndices.add(index);
911
+ }
809
912
  }
810
913
  const requestedIndices = config.resumeChunkIndices ? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks)) : void 0;
811
- const shouldSynthesize = (index) => !cachedChunks.has(index) && (requestedIndices === void 0 || requestedIndices.has(index));
914
+ const shouldSynthesize = (index) => (!cachedChunks.has(index) || invalidCachedIndices.has(index)) && (requestedIndices === void 0 || requestedIndices.has(index) || invalidCachedIndices.has(index));
915
+ const jobStartedAt = Date.now();
916
+ const jobDeadlineAt = config.timeouts?.totalJobMs !== void 0 && config.timeouts.totalJobMs > 0 ? jobStartedAt + config.timeouts.totalJobMs : void 0;
812
917
  const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
813
918
  const report = (event) => config.onProgress?.(event);
814
- for (const [index, chunk] of chunks.entries()) {
815
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
919
+ for (const [index, input] of inputs.entries()) {
816
920
  report({
817
921
  currentChunk: index,
818
922
  totalChunks,
@@ -834,8 +938,7 @@ async function synthesizeSsmlChunks(chunks, config) {
834
938
  if (index >= chunks.length) return;
835
939
  if (!shouldSynthesize(index)) continue;
836
940
  if (firstError && config.cancelOnFailure !== false) return;
837
- const chunk = chunks[index];
838
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
941
+ const input = inputs[index];
839
942
  report({
840
943
  currentChunk: completed,
841
944
  totalChunks,
@@ -872,9 +975,11 @@ async function synthesizeSsmlChunks(chunks, config) {
872
975
  retryAttempt,
873
976
  nextRetryDelayMs,
874
977
  isRetrying: true
875
- })
978
+ }),
979
+ jobDeadlineAt
876
980
  );
877
981
  results[index] = result;
982
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result };
878
983
  completed += 1;
879
984
  report({
880
985
  currentChunk: completed,
@@ -886,7 +991,16 @@ async function synthesizeSsmlChunks(chunks, config) {
886
991
  durationMs: Date.now() - startedAt
887
992
  });
888
993
  } catch (error) {
889
- failedIndices.add(index);
994
+ const wasCancelled = firstError !== void 0 || scope.signal.aborted && !scope.timedOut();
995
+ firstError ?? (firstError = scope.timedOut() ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`) : error);
996
+ if (!wasCancelled) failedIndices.add(index);
997
+ chunkStates[index] = {
998
+ chunkIndex: index,
999
+ status: wasCancelled ? "cancelled" : "failed",
1000
+ isOriginalFailure: !wasCancelled,
1001
+ canResume: true,
1002
+ error
1003
+ };
890
1004
  report({
891
1005
  currentChunk: completed,
892
1006
  totalChunks,
@@ -897,7 +1011,6 @@ async function synthesizeSsmlChunks(chunks, config) {
897
1011
  durationMs: Date.now() - startedAt,
898
1012
  error
899
1013
  });
900
- firstError ?? (firstError = scope.timedOut() ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`) : error);
901
1014
  if (config.cancelOnFailure !== false) scope.abort();
902
1015
  return;
903
1016
  }
@@ -915,11 +1028,25 @@ async function synthesizeSsmlChunks(chunks, config) {
915
1028
  postMergeValidator: config.postMergeValidator
916
1029
  });
917
1030
  } catch (error) {
1031
+ if (firstError && config.cancelOnFailure !== false) {
1032
+ for (const [chunkIndex, state] of chunkStates.entries()) {
1033
+ if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
1034
+ chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
1035
+ }
1036
+ }
1037
+ }
1038
+ const synthesizedChunks = results.flatMap(
1039
+ (result, chunkIndex) => result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : []
1040
+ );
918
1041
  const partial = {
919
- synthesizedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
920
- completedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
921
- pendingChunkIndices: chunks.flatMap((_chunk, chunkIndex) => results[chunkIndex] ? [] : [chunkIndex]),
1042
+ synthesizedChunks,
1043
+ completedChunks: synthesizedChunks,
1044
+ pendingChunkIndices: chunkStates.flatMap(
1045
+ (state) => state.status === "pending" || state.status === "cancelled" || state.status === "failed" ? [state.chunkIndex] : []
1046
+ ),
922
1047
  failedChunkIndices: [...failedIndices],
1048
+ cancelledChunkIndices: chunkStates.filter((state) => state.status === "cancelled").map((state) => state.chunkIndex),
1049
+ chunkStates,
923
1050
  totalChunks
924
1051
  };
925
1052
  if (error && typeof error === "object") error.partialResult = partial;
@@ -1017,16 +1144,15 @@ function mergeSynthesisResults(results, options) {
1017
1144
  })
1018
1145
  ).then((merged) => {
1019
1146
  if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
1020
- if (!(merged instanceof ArrayBuffer) || buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
1021
- throw new MergeError("The custom audio merger returned an invalid audio buffer.");
1022
1147
  if (signal.aborted) throw new SynthesisCancelledError();
1023
- const result = createMergedResult(
1024
- results,
1148
+ const mergedSpec = validateMergedAudioBuffer(
1025
1149
  merged,
1026
1150
  format,
1027
- inspectAudioSpecification(merged, format),
1028
- resolvedOptions.outputMimeType
1151
+ buffers,
1152
+ inputSpecs,
1153
+ resolvedOptions.outputMimeType ?? resolveMimeType(format)
1029
1154
  );
1155
+ const result = createMergedResult(results, merged, format, mergedSpec, resolvedOptions.outputMimeType);
1030
1156
  return Promise.resolve(
1031
1157
  resolvedOptions.postMergeValidator?.(result, {
1032
1158
  format,
@@ -1156,7 +1282,7 @@ function resolveConcurrency2(value, total) {
1156
1282
  if (value === Infinity) return Math.max(1, total);
1157
1283
  return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
1158
1284
  }
1159
- async function retryableSynthesis(synthesize, options, signal, onRetry) {
1285
+ async function retryableSynthesis(synthesize, options, signal, onRetry, deadlineAtMs) {
1160
1286
  const retry = options ? {
1161
1287
  maxRetries: Math.max(0, Math.floor(options.maxRetries)),
1162
1288
  initialDelayMs: options.initialDelayMs,
@@ -1173,6 +1299,11 @@ async function retryableSynthesis(synthesize, options, signal, onRetry) {
1173
1299
  throw error;
1174
1300
  attempt += 1;
1175
1301
  const delayMs = retryDelayForError(retry, attempt, error);
1302
+ const retryAfterMs = getRetryAfterDelayMs(error);
1303
+ const remainingMs = deadlineAtMs === void 0 ? void 0 : Math.max(0, deadlineAtMs - Date.now());
1304
+ if (retryAfterMs !== void 0 && (retryAfterMs > retry.maxDelayMs || remainingMs !== void 0 && retryAfterMs > remainingMs)) {
1305
+ throw new Error("Speech synthesis timed out because Retry-After exceeded the available retry budget.");
1306
+ }
1176
1307
  onRetry(attempt, delayMs);
1177
1308
  if (delayMs > 0)
1178
1309
  await new Promise((resolve, reject) => {
@@ -1220,20 +1351,24 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
1220
1351
  diagnostics: errors
1221
1352
  });
1222
1353
  }
1354
+ const jobScope = options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts.totalJobMs) : void 0;
1223
1355
  try {
1224
1356
  return {
1225
1357
  ok: true,
1226
1358
  success: true,
1227
1359
  status: "success",
1228
1360
  value: await client.synthesizeSsml(ssml, {
1229
- signal: options.signal,
1361
+ signal: jobScope?.signal ?? options.signal,
1230
1362
  timeoutMs: options.timeouts?.perChunkMs,
1231
1363
  timeouts: options.timeouts
1232
1364
  })
1233
1365
  };
1234
1366
  } catch (error) {
1367
+ if (jobScope?.timedOut()) return failure(toSynthesisError(new Error("Speech synthesis timed out.")));
1235
1368
  const synthesisError = toSynthesisError(error);
1236
1369
  return failure(synthesisError);
1370
+ } finally {
1371
+ jobScope?.dispose();
1237
1372
  }
1238
1373
  }
1239
1374
  async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
@@ -1305,17 +1440,32 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1305
1440
  resumeChunkIndices: options.resumeChunkIndices,
1306
1441
  customMerger: options.customMerger,
1307
1442
  outputMimeType: options.outputMimeType,
1308
- postMergeValidator: options.postMergeValidator
1443
+ postMergeValidator: options.postMergeValidator,
1444
+ resumeValidation: options.resumeValidation
1309
1445
  });
1310
1446
  return { ok: true, success: true, status: "success", value };
1311
1447
  }
1448
+ const inputs = chunks.map((chunk) => typeof chunk === "string" ? { ssml: chunk } : chunk);
1449
+ const fingerprints = inputs.map((chunk) => computeChunkFingerprint(chunk.ssml, options.outputFormat));
1312
1450
  const results = new Array(chunks.length);
1451
+ const chunkStates = inputs.map((_chunk, chunkIndex) => ({
1452
+ chunkIndex,
1453
+ status: "pending",
1454
+ canResume: true
1455
+ }));
1313
1456
  const cachedChunks = new Map((options.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
1457
+ const invalidCachedIndices = /* @__PURE__ */ new Set();
1314
1458
  for (const [index, cached] of cachedChunks) {
1315
- if (index >= 0 && index < chunks.length) results[index] = cached;
1459
+ if (index < 0 || index >= chunks.length) continue;
1460
+ if (options.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index]) {
1461
+ results[index] = cached;
1462
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: cached };
1463
+ } else invalidCachedIndices.add(index);
1316
1464
  }
1317
1465
  const requestedIndices = options.resumeChunkIndices ? new Set(options.resumeChunkIndices.filter((index) => index >= 0 && index < chunks.length)) : void 0;
1318
- const shouldSynthesize = (index) => !cachedChunks.has(index) && (requestedIndices === void 0 || requestedIndices.has(index));
1466
+ const shouldSynthesize = (index) => (!cachedChunks.has(index) || invalidCachedIndices.has(index)) && (requestedIndices === void 0 || requestedIndices.has(index) || invalidCachedIndices.has(index));
1467
+ const jobStartedAt = Date.now();
1468
+ const jobDeadlineAt = options.timeouts?.totalJobMs !== void 0 && options.timeouts.totalJobMs > 0 ? jobStartedAt + options.timeouts.totalJobMs : void 0;
1319
1469
  const jobScope = chunks.length > 1 || options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs) : void 0;
1320
1470
  fallbackJobScope = jobScope;
1321
1471
  const failedIndices = /* @__PURE__ */ new Set();
@@ -1328,7 +1478,10 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1328
1478
  const index = nextIndex++;
1329
1479
  if (index >= chunks.length) return;
1330
1480
  if (!shouldSynthesize(index)) continue;
1331
- if (failedIndices.size > 0 && options.cancelOnFailure !== false) return;
1481
+ if (firstError && options.cancelOnFailure !== false) {
1482
+ chunkStates[index] = { chunkIndex: index, status: "cancelled", isOriginalFailure: false, canResume: true };
1483
+ return;
1484
+ }
1332
1485
  const chunk = chunks[index];
1333
1486
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
1334
1487
  const sourceNodePath = input.sourceNodePath;
@@ -1361,7 +1514,8 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1361
1514
  retryAttempt,
1362
1515
  nextRetryDelayMs,
1363
1516
  isRetrying: true
1364
- })
1517
+ }),
1518
+ jobDeadlineAt
1365
1519
  );
1366
1520
  } catch (error) {
1367
1521
  if (chunkScope?.timedOut())
@@ -1413,6 +1567,7 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1413
1567
  }))
1414
1568
  } : {}
1415
1569
  };
1570
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
1416
1571
  completed += 1;
1417
1572
  options.onProgress?.({
1418
1573
  currentChunk: completed,
@@ -1424,7 +1579,16 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1424
1579
  durationMs: Date.now() - startedAt
1425
1580
  });
1426
1581
  } catch (error) {
1427
- failedIndices.add(index);
1582
+ const wasCancelled = firstError !== void 0 || Boolean(jobScope?.signal.aborted && !jobScope?.timedOut());
1583
+ firstError ?? (firstError = error);
1584
+ if (!wasCancelled) failedIndices.add(index);
1585
+ chunkStates[index] = {
1586
+ chunkIndex: index,
1587
+ status: wasCancelled ? "cancelled" : "failed",
1588
+ isOriginalFailure: !wasCancelled,
1589
+ canResume: true,
1590
+ error
1591
+ };
1428
1592
  options.onProgress?.({
1429
1593
  currentChunk: completed,
1430
1594
  totalChunks: chunks.length,
@@ -1436,19 +1600,32 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
1436
1600
  error
1437
1601
  });
1438
1602
  if (options.cancelOnFailure !== false) jobScope?.abort();
1439
- firstError ?? (firstError = error);
1440
1603
  return;
1441
1604
  }
1442
1605
  }
1443
1606
  };
1444
1607
  await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
1608
+ if (firstError && options.cancelOnFailure !== false) {
1609
+ for (const [chunkIndex, state] of chunkStates.entries()) {
1610
+ if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
1611
+ chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
1612
+ }
1613
+ }
1614
+ }
1445
1615
  if (failedIndices.size > 0) {
1446
1616
  const error = firstError ?? new Error("One or more SSML chunks failed to synthesize.");
1617
+ const synthesizedChunks = results.flatMap(
1618
+ (result, chunkIndex) => result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : []
1619
+ );
1447
1620
  error.partialResult = {
1448
- synthesizedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
1449
- completedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
1450
- pendingChunkIndices: chunks.flatMap((_chunk, chunkIndex) => results[chunkIndex] ? [] : [chunkIndex]),
1621
+ synthesizedChunks,
1622
+ completedChunks: synthesizedChunks,
1623
+ pendingChunkIndices: chunkStates.flatMap(
1624
+ (state) => state.status === "pending" || state.status === "cancelled" || state.status === "failed" ? [state.chunkIndex] : []
1625
+ ),
1451
1626
  failedChunkIndices: [...failedIndices],
1627
+ cancelledChunkIndices: chunkStates.filter((state) => state.status === "cancelled").map((state) => state.chunkIndex),
1628
+ chunkStates,
1452
1629
  totalChunks: chunks.length
1453
1630
  };
1454
1631
  throw error;
@@ -1494,7 +1671,16 @@ var AzureTtsClient = class {
1494
1671
  const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
1495
1672
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
1496
1673
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
1497
- const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts };
1674
+ const config = {
1675
+ endpoint,
1676
+ region,
1677
+ subscriptionKey,
1678
+ outputFormat,
1679
+ signal,
1680
+ timeoutMs,
1681
+ timeouts,
1682
+ retryOptions: __privateGet(this, _options).retryOptions
1683
+ };
1498
1684
  return synthesizeSpeech(ssml, config);
1499
1685
  }
1500
1686
  async synthesizeSsml(ssml, options = {}) {
@@ -1511,7 +1697,13 @@ var AzureTtsClient = class {
1511
1697
  timeouts: options.timeouts ?? timeouts,
1512
1698
  sourceNodePath: options.sourceNodePath,
1513
1699
  sourceTextSegments: options.sourceTextSegments,
1514
- sourceMarkers: options.sourceMarkers
1700
+ sourceMarkers: options.sourceMarkers,
1701
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
1702
+ cancelOnFailure: options.cancelOnFailure ?? __privateGet(this, _options).cancelOnFailure,
1703
+ customMerger: options.customMerger ?? __privateGet(this, _options).customMerger,
1704
+ outputMimeType: options.outputMimeType ?? __privateGet(this, _options).outputMimeType,
1705
+ postMergeValidator: options.postMergeValidator ?? __privateGet(this, _options).postMergeValidator,
1706
+ resumeValidation: options.resumeValidation ?? __privateGet(this, _options).resumeValidation
1515
1707
  });
1516
1708
  }
1517
1709
  async synthesizeChunks(chunks, options = {}) {
@@ -1529,12 +1721,13 @@ var AzureTtsClient = class {
1529
1721
  onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1530
1722
  concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1531
1723
  retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
1532
- cancelOnFailure: options.cancelOnFailure,
1724
+ cancelOnFailure: options.cancelOnFailure ?? __privateGet(this, _options).cancelOnFailure,
1533
1725
  resumeChunks: options.resumeChunks,
1534
1726
  resumeChunkIndices: options.resumeChunkIndices,
1535
- customMerger: options.customMerger,
1536
- outputMimeType: options.outputMimeType,
1537
- postMergeValidator: options.postMergeValidator
1727
+ customMerger: options.customMerger ?? __privateGet(this, _options).customMerger,
1728
+ outputMimeType: options.outputMimeType ?? __privateGet(this, _options).outputMimeType,
1729
+ postMergeValidator: options.postMergeValidator ?? __privateGet(this, _options).postMergeValidator,
1730
+ resumeValidation: options.resumeValidation ?? __privateGet(this, _options).resumeValidation
1538
1731
  });
1539
1732
  }
1540
1733
  async synthesizeSsmlSafe(ssml, options = {}) {
@@ -1660,6 +1853,7 @@ async function fetchAzureVoiceCatalog(options) {
1660
1853
  SynthesisTimeoutError,
1661
1854
  UnsupportedMergeFormatError,
1662
1855
  canMergeAudioFormat,
1856
+ computeChunkFingerprint,
1663
1857
  fetchAzureVoiceCatalog,
1664
1858
  getRetryAfterDelayMs,
1665
1859
  inspectAudioSpecification,