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.js CHANGED
@@ -52,6 +52,7 @@ __export(src_exports, {
52
52
  buildPartialSsml: () => buildPartialSsml,
53
53
  buildSsml: () => buildSsml,
54
54
  canMergeAudioFormat: () => canMergeAudioFormat,
55
+ computeChunkFingerprint: () => computeChunkFingerprint,
55
56
  createAzureUrlValidatorRunner: () => createAzureUrlValidatorRunner,
56
57
  extractSsmlText: () => extractSsmlText,
57
58
  extractSsmlTranslatableText: () => extractSsmlTranslatableText,
@@ -4583,6 +4584,9 @@ var OUTPUT_FORMATS = {
4583
4584
  function resolveMimeType(outputFormat) {
4584
4585
  if (/(?:wav|wave|riff)/i.test(outputFormat)) return "audio/wav";
4585
4586
  if (/(?:mp3|mpeg)/i.test(outputFormat)) return "audio/mpeg";
4587
+ if (/mulaw|mu-law/i.test(outputFormat)) return "audio/basic";
4588
+ if (/alaw|a-law/i.test(outputFormat)) return "audio/alaw";
4589
+ if (/siren/i.test(outputFormat)) return "audio/siren";
4586
4590
  if (/ogg/i.test(outputFormat)) return "audio/ogg";
4587
4591
  if (/webm/i.test(outputFormat)) return "audio/webm";
4588
4592
  if (/raw/i.test(outputFormat)) return "audio/L16";
@@ -4611,6 +4615,27 @@ function createSpeechConfig(config) {
4611
4615
  }
4612
4616
 
4613
4617
  // packages/azure-tts-client/src/synthesis.ts
4618
+ function computeChunkFingerprint(ssml, outputFormat = DEFAULT_OUTPUT_FORMAT) {
4619
+ const readAttribute3 = (name) => {
4620
+ const pattern = new RegExp(`(?:${name})\\s*=\\s*[\\"']([^\\"']*)`, "gi");
4621
+ return [...ssml.matchAll(pattern)].map((match) => match[1] ?? "").join("|");
4622
+ };
4623
+ const payload = JSON.stringify({
4624
+ ssml,
4625
+ outputFormat,
4626
+ voice: readAttribute3("(?:name|voice)"),
4627
+ language: readAttribute3("(?:xml:lang|lang)"),
4628
+ rate: readAttribute3("rate"),
4629
+ pitch: readAttribute3("pitch")
4630
+ });
4631
+ let hash = 0xcbf29ce484222325n;
4632
+ const mask = 0xffffffffffffffffn;
4633
+ for (let index = 0; index < payload.length; index += 1) {
4634
+ hash ^= BigInt(payload.charCodeAt(index));
4635
+ hash = hash * 0x100000001b3n & mask;
4636
+ }
4637
+ return `fnv1a64-${hash.toString(16).padStart(16, "0")}`;
4638
+ }
4614
4639
  function ascii(bytes, offset, value) {
4615
4640
  return [...value].every((character, index) => bytes[offset + index] === character.charCodeAt(0));
4616
4641
  }
@@ -4650,9 +4675,11 @@ function parseWav(buffer) {
4650
4675
  }
4651
4676
  return { chunks, data, format };
4652
4677
  }
4653
- function formatNumber(format, pattern, fallback) {
4654
- const match = pattern.exec(format);
4655
- return match?.[1] ? Number(match[1]) : fallback;
4678
+ function formatSampleRate(format) {
4679
+ const match = /(?:^|-)(\d+)(khz|hz)(?:-|$)/i.exec(format);
4680
+ if (!match?.[1] || !match[2]) return 0;
4681
+ const value = Number(match[1]);
4682
+ return match[2].toLowerCase() === "khz" ? value * 1e3 : value;
4656
4683
  }
4657
4684
  function formatChannels(format, fallback) {
4658
4685
  if (/stereo|2ch|dual/i.test(format)) return 2;
@@ -4660,11 +4687,11 @@ function formatChannels(format, fallback) {
4660
4687
  return fallback;
4661
4688
  }
4662
4689
  function formatAudioSpecification(format) {
4663
- const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
4690
+ const sampleRate = formatSampleRate(format);
4664
4691
  const channels = formatChannels(format, 0);
4665
4692
  const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
4666
4693
  const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1e3 : void 0;
4667
- 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";
4694
+ 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";
4668
4695
  const bitDepthMatch = /(\d+)bit/i.exec(format);
4669
4696
  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;
4670
4697
  return {
@@ -4677,7 +4704,7 @@ function formatAudioSpecification(format) {
4677
4704
  ...bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {},
4678
4705
  ...container ? { container } : {},
4679
4706
  isVbr: /vbr/i.test(format),
4680
- isCompressed: codec === "mp3" || codec === "opus" || codec === "silk"
4707
+ isCompressed: codec !== "pcm" && codec !== "unknown"
4681
4708
  };
4682
4709
  }
4683
4710
  function parseMp3Specification(buffer, format) {
@@ -4728,27 +4755,43 @@ function inspectAudioSpecification(buffer, format) {
4728
4755
  const channels = view.getUint16(2, true);
4729
4756
  const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
4730
4757
  const formatCode = view.getUint16(0, true);
4758
+ const namedCodec = formatAudioSpecification(format).codec;
4759
+ const codec = formatCode === 1 ? "pcm" : formatCode === 6 ? "alaw" : formatCode === 7 ? "mulaw" : namedCodec === "siren" ? "siren" : "unknown";
4731
4760
  return {
4732
4761
  format,
4733
4762
  mimeType: "audio/wav",
4734
- codec: formatCode === 1 ? "pcm" : "unknown",
4763
+ codec,
4735
4764
  sampleRate,
4736
4765
  channels,
4737
4766
  ...sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {},
4738
4767
  bitDepth: bitsPerSample,
4739
4768
  container: "riff-wave",
4740
4769
  isVbr: false,
4741
- isCompressed: formatCode !== 1
4770
+ isCompressed: codec !== "pcm" && codec !== "unknown"
4742
4771
  };
4743
4772
  }
4744
4773
  if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
4745
- return formatAudioSpecification(format);
4774
+ const specification = formatAudioSpecification(format);
4775
+ if (specification.container === "raw") validateRawAudioBuffer(buffer, specification);
4776
+ return specification;
4777
+ }
4778
+ function validateRawAudioBuffer(buffer, specification) {
4779
+ if (specification.sampleRate <= 0 || specification.channels <= 0 || specification.bitDepth === void 0) {
4780
+ throw new Error(`RAW audio format "${specification.format}" does not define a complete audio specification.`);
4781
+ }
4782
+ if (specification.codec === "siren" || specification.codec === "silk") return;
4783
+ const bytesPerFrame = specification.channels * Math.ceil(specification.bitDepth / 8);
4784
+ if (bytesPerFrame <= 0 || buffer.byteLength % bytesPerFrame !== 0) {
4785
+ throw new Error(
4786
+ `RAW audio buffer size ${buffer.byteLength} is not aligned to ${bytesPerFrame}-byte audio frames for "${specification.format}".`
4787
+ );
4788
+ }
4746
4789
  }
4747
4790
  function validateAudioSpecifications(specs) {
4748
4791
  const first = specs[0];
4749
4792
  if (!first) return;
4750
4793
  const mismatch = specs.find(
4751
- (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
4794
+ (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
4752
4795
  );
4753
4796
  if (mismatch)
4754
4797
  throw new AudioFormatMismatchError(
@@ -4831,6 +4874,30 @@ function isWavFormat(format) {
4831
4874
  function isRawFormat(format) {
4832
4875
  return /^raw(?:-|$)/i.test(format);
4833
4876
  }
4877
+ function validateMergedAudioBuffer(merged, format, buffers, inputSpecs, outputMimeType) {
4878
+ if (!(merged instanceof ArrayBuffer) || merged.byteLength === 0) {
4879
+ throw new MergeError("The custom audio merger returned an empty or invalid audio buffer.");
4880
+ }
4881
+ const specification = inspectAudioSpecification(merged, format);
4882
+ if (!outputMimeType.trim()) throw new MergeError("The custom audio merger output MIME type cannot be empty.");
4883
+ const firstInput = inputSpecs[0];
4884
+ if (firstInput && (specification.sampleRate !== firstInput.sampleRate || specification.channels !== firstInput.channels || specification.codec !== firstInput.codec || firstInput.bitDepth !== void 0 && specification.bitDepth !== firstInput.bitDepth)) {
4885
+ throw new AudioFormatMismatchError("The custom audio merger returned an incompatible audio stream.", [
4886
+ ...inputSpecs,
4887
+ specification
4888
+ ]);
4889
+ }
4890
+ if (isRawFormat(format)) {
4891
+ const expectedSize = buffers.reduce((total, input) => total + input.byteLength, 0);
4892
+ if (merged.byteLength !== expectedSize) {
4893
+ throw new MergeError(
4894
+ `The custom audio merger returned ${merged.byteLength} bytes; ${expectedSize} were expected.`
4895
+ );
4896
+ }
4897
+ validateRawAudioBuffer(merged, specification);
4898
+ }
4899
+ return specification;
4900
+ }
4834
4901
  function resolveMergeAudioFormat(format) {
4835
4902
  if (isWavFormat(format)) return "wav";
4836
4903
  if (isMp3Format(format)) return "mp3";
@@ -4883,7 +4950,7 @@ function closeSpeechResources(speechConfig, synthesizer) {
4883
4950
  }
4884
4951
  }
4885
4952
  var ticksToMilliseconds = (ticks) => Math.max(0, ticks) / 1e4;
4886
- async function synthesizeSsml(ssml, config) {
4953
+ async function synthesizeSsmlOnce(ssml, config) {
4887
4954
  if (config.signal?.aborted) {
4888
4955
  throw new SynthesisCancelledError();
4889
4956
  }
@@ -5013,6 +5080,13 @@ async function synthesizeSsml(ssml, config) {
5013
5080
  rejectWithError(err);
5014
5081
  return;
5015
5082
  }
5083
+ let audioSpec;
5084
+ try {
5085
+ audioSpec = inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT);
5086
+ } catch (error) {
5087
+ rejectWithError(error);
5088
+ return;
5089
+ }
5016
5090
  settled = true;
5017
5091
  cleanup();
5018
5092
  closeResources();
@@ -5048,8 +5122,8 @@ async function synthesizeSsml(ssml, config) {
5048
5122
  resolve({
5049
5123
  audioData: result.audioData,
5050
5124
  durationMs,
5051
- audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
5052
- mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
5125
+ audioSpec,
5126
+ mimeType: config.outputMimeType ?? resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
5053
5127
  ...config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {},
5054
5128
  ...requestId ? { requestId } : {},
5055
5129
  ...sourceBoundaries.length > 0 ? { boundaries: sourceBoundaries, wordBoundary: sourceBoundaries, wordBoundaries: sourceBoundaries } : {},
@@ -5062,7 +5136,7 @@ async function synthesizeSsml(ssml, config) {
5062
5136
  abortHandler = () => rejectWithError(new SynthesisCancelledError());
5063
5137
  config.signal.addEventListener("abort", abortHandler, { once: true });
5064
5138
  }
5065
- const timeoutMs = config.timeouts?.perChunkMs ?? config.timeoutMs;
5139
+ const timeoutMs = config.timeouts?.perChunkMs ?? config.timeouts?.totalJobMs ?? config.timeoutMs;
5066
5140
  if (timeoutMs !== void 0 && timeoutMs > 0) {
5067
5141
  timeout = setTimeout(
5068
5142
  () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
@@ -5115,7 +5189,7 @@ async function waitForRetry(delayMs, signal) {
5115
5189
  }
5116
5190
  });
5117
5191
  }
5118
- async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
5192
+ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry, deadlineAtMs) {
5119
5193
  const options = retryOptions ? {
5120
5194
  maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
5121
5195
  initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
@@ -5126,17 +5200,29 @@ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
5126
5200
  while (true) {
5127
5201
  if (config.signal?.aborted) throw new SynthesisCancelledError();
5128
5202
  try {
5129
- return await synthesizeSsml(ssml, config);
5203
+ return await synthesizeSsmlOnce(ssml, config);
5130
5204
  } catch (error) {
5131
5205
  if (!options || attempt >= options.maxRetries || !(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error)))
5132
5206
  throw error;
5133
5207
  attempt += 1;
5134
5208
  const delayMs = retryDelay(options, attempt, error);
5209
+ const remainingMs = deadlineAtMs === void 0 ? void 0 : Math.max(0, deadlineAtMs - Date.now());
5210
+ if (getRetryAfterDelayMs(error) !== void 0 && (delayMs > options.maxDelayMs || remainingMs !== void 0 && delayMs > remainingMs)) {
5211
+ throw new SynthesisTimeoutError(
5212
+ remainingMs === void 0 ? `Retry-After exceeded maxDelayMs (${options.maxDelayMs} ms).` : `Retry-After exceeded the remaining total job timeout (${remainingMs} ms).`
5213
+ );
5214
+ }
5135
5215
  onRetry(attempt, delayMs);
5136
- await waitForRetry(delayMs, config.signal);
5216
+ await waitForRetry(Math.min(delayMs, remainingMs ?? delayMs), config.signal);
5137
5217
  }
5138
5218
  }
5139
5219
  }
5220
+ async function synthesizeSsml(ssml, config) {
5221
+ const totalJobMs = config.timeouts?.totalJobMs;
5222
+ const deadlineAtMs = totalJobMs !== void 0 && totalJobMs > 0 ? Date.now() + totalJobMs : void 0;
5223
+ if (!config.retryOptions) return synthesizeSsmlOnce(ssml, config);
5224
+ return synthesizeWithRetry(ssml, config, config.retryOptions, () => void 0, deadlineAtMs);
5225
+ }
5140
5226
  function createAbortScope(parent, timeoutMs) {
5141
5227
  const controller = new AbortController();
5142
5228
  let didTimeout = false;
@@ -5157,10 +5243,10 @@ function createAbortScope(parent, timeoutMs) {
5157
5243
  abort: () => controller.abort()
5158
5244
  };
5159
5245
  }
5160
- async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs, onRetry) {
5246
+ async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs, onRetry, deadlineAtMs) {
5161
5247
  const scope = createAbortScope(config.signal, timeoutMs);
5162
5248
  try {
5163
- return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry);
5249
+ return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry, deadlineAtMs);
5164
5250
  } catch (error) {
5165
5251
  if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
5166
5252
  throw error;
@@ -5169,18 +5255,36 @@ async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs,
5169
5255
  }
5170
5256
  }
5171
5257
  async function synthesizeSsmlChunks(chunks, config) {
5172
- const results = new Array(chunks.length);
5173
5258
  const totalChunks = chunks.length;
5259
+ const inputs = chunks.map((chunk) => typeof chunk === "string" ? { ssml: chunk } : chunk);
5260
+ const fingerprints = inputs.map(
5261
+ (chunk) => computeChunkFingerprint(chunk.ssml, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT)
5262
+ );
5263
+ const results = new Array(totalChunks);
5174
5264
  const cachedChunks = new Map((config.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
5265
+ const invalidCachedIndices = /* @__PURE__ */ new Set();
5266
+ const chunkStates = inputs.map((_chunk, chunkIndex) => ({
5267
+ chunkIndex,
5268
+ status: "pending",
5269
+ canResume: true
5270
+ }));
5175
5271
  for (const [index, cached] of cachedChunks) {
5176
- if (index >= 0 && index < totalChunks) results[index] = cached;
5272
+ if (index < 0 || index >= totalChunks) continue;
5273
+ const isValid = config.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index];
5274
+ if (isValid) {
5275
+ results[index] = { ...cached };
5276
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
5277
+ } else {
5278
+ invalidCachedIndices.add(index);
5279
+ }
5177
5280
  }
5178
5281
  const requestedIndices = config.resumeChunkIndices ? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks)) : void 0;
5179
- const shouldSynthesize = (index) => !cachedChunks.has(index) && (requestedIndices === void 0 || requestedIndices.has(index));
5282
+ const shouldSynthesize = (index) => (!cachedChunks.has(index) || invalidCachedIndices.has(index)) && (requestedIndices === void 0 || requestedIndices.has(index) || invalidCachedIndices.has(index));
5283
+ const jobStartedAt = Date.now();
5284
+ const jobDeadlineAt = config.timeouts?.totalJobMs !== void 0 && config.timeouts.totalJobMs > 0 ? jobStartedAt + config.timeouts.totalJobMs : void 0;
5180
5285
  const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
5181
5286
  const report = (event) => config.onProgress?.(event);
5182
- for (const [index, chunk] of chunks.entries()) {
5183
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
5287
+ for (const [index, input] of inputs.entries()) {
5184
5288
  report({
5185
5289
  currentChunk: index,
5186
5290
  totalChunks,
@@ -5202,8 +5306,7 @@ async function synthesizeSsmlChunks(chunks, config) {
5202
5306
  if (index >= chunks.length) return;
5203
5307
  if (!shouldSynthesize(index)) continue;
5204
5308
  if (firstError && config.cancelOnFailure !== false) return;
5205
- const chunk = chunks[index];
5206
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
5309
+ const input = inputs[index];
5207
5310
  report({
5208
5311
  currentChunk: completed,
5209
5312
  totalChunks,
@@ -5240,9 +5343,11 @@ async function synthesizeSsmlChunks(chunks, config) {
5240
5343
  retryAttempt,
5241
5344
  nextRetryDelayMs,
5242
5345
  isRetrying: true
5243
- })
5346
+ }),
5347
+ jobDeadlineAt
5244
5348
  );
5245
5349
  results[index] = result;
5350
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result };
5246
5351
  completed += 1;
5247
5352
  report({
5248
5353
  currentChunk: completed,
@@ -5254,7 +5359,16 @@ async function synthesizeSsmlChunks(chunks, config) {
5254
5359
  durationMs: Date.now() - startedAt
5255
5360
  });
5256
5361
  } catch (error) {
5257
- failedIndices.add(index);
5362
+ const wasCancelled = firstError !== void 0 || scope.signal.aborted && !scope.timedOut();
5363
+ firstError ?? (firstError = scope.timedOut() ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`) : error);
5364
+ if (!wasCancelled) failedIndices.add(index);
5365
+ chunkStates[index] = {
5366
+ chunkIndex: index,
5367
+ status: wasCancelled ? "cancelled" : "failed",
5368
+ isOriginalFailure: !wasCancelled,
5369
+ canResume: true,
5370
+ error
5371
+ };
5258
5372
  report({
5259
5373
  currentChunk: completed,
5260
5374
  totalChunks,
@@ -5265,7 +5379,6 @@ async function synthesizeSsmlChunks(chunks, config) {
5265
5379
  durationMs: Date.now() - startedAt,
5266
5380
  error
5267
5381
  });
5268
- firstError ?? (firstError = scope.timedOut() ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`) : error);
5269
5382
  if (config.cancelOnFailure !== false) scope.abort();
5270
5383
  return;
5271
5384
  }
@@ -5283,11 +5396,25 @@ async function synthesizeSsmlChunks(chunks, config) {
5283
5396
  postMergeValidator: config.postMergeValidator
5284
5397
  });
5285
5398
  } catch (error) {
5399
+ if (firstError && config.cancelOnFailure !== false) {
5400
+ for (const [chunkIndex, state] of chunkStates.entries()) {
5401
+ if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
5402
+ chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
5403
+ }
5404
+ }
5405
+ }
5406
+ const synthesizedChunks = results.flatMap(
5407
+ (result, chunkIndex) => result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : []
5408
+ );
5286
5409
  const partial = {
5287
- synthesizedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
5288
- completedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
5289
- pendingChunkIndices: chunks.flatMap((_chunk, chunkIndex) => results[chunkIndex] ? [] : [chunkIndex]),
5410
+ synthesizedChunks,
5411
+ completedChunks: synthesizedChunks,
5412
+ pendingChunkIndices: chunkStates.flatMap(
5413
+ (state) => state.status === "pending" || state.status === "cancelled" || state.status === "failed" ? [state.chunkIndex] : []
5414
+ ),
5290
5415
  failedChunkIndices: [...failedIndices],
5416
+ cancelledChunkIndices: chunkStates.filter((state) => state.status === "cancelled").map((state) => state.chunkIndex),
5417
+ chunkStates,
5291
5418
  totalChunks
5292
5419
  };
5293
5420
  if (error && typeof error === "object") error.partialResult = partial;
@@ -5385,16 +5512,15 @@ function mergeSynthesisResults(results, options) {
5385
5512
  })
5386
5513
  ).then((merged) => {
5387
5514
  if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
5388
- if (!(merged instanceof ArrayBuffer) || buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
5389
- throw new MergeError("The custom audio merger returned an invalid audio buffer.");
5390
5515
  if (signal.aborted) throw new SynthesisCancelledError();
5391
- const result = createMergedResult(
5392
- results,
5516
+ const mergedSpec = validateMergedAudioBuffer(
5393
5517
  merged,
5394
5518
  format,
5395
- inspectAudioSpecification(merged, format),
5396
- resolvedOptions.outputMimeType
5519
+ buffers,
5520
+ inputSpecs,
5521
+ resolvedOptions.outputMimeType ?? resolveMimeType(format)
5397
5522
  );
5523
+ const result = createMergedResult(results, merged, format, mergedSpec, resolvedOptions.outputMimeType);
5398
5524
  return Promise.resolve(
5399
5525
  resolvedOptions.postMergeValidator?.(result, {
5400
5526
  format,
@@ -5523,7 +5649,7 @@ function resolveConcurrency2(value, total) {
5523
5649
  if (value === Infinity) return Math.max(1, total);
5524
5650
  return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
5525
5651
  }
5526
- async function retryableSynthesis(synthesize, options, signal, onRetry) {
5652
+ async function retryableSynthesis(synthesize, options, signal, onRetry, deadlineAtMs) {
5527
5653
  const retry = options ? {
5528
5654
  maxRetries: Math.max(0, Math.floor(options.maxRetries)),
5529
5655
  initialDelayMs: options.initialDelayMs,
@@ -5540,6 +5666,11 @@ async function retryableSynthesis(synthesize, options, signal, onRetry) {
5540
5666
  throw error;
5541
5667
  attempt += 1;
5542
5668
  const delayMs = retryDelayForError(retry, attempt, error);
5669
+ const retryAfterMs = getRetryAfterDelayMs(error);
5670
+ const remainingMs = deadlineAtMs === void 0 ? void 0 : Math.max(0, deadlineAtMs - Date.now());
5671
+ if (retryAfterMs !== void 0 && (retryAfterMs > retry.maxDelayMs || remainingMs !== void 0 && retryAfterMs > remainingMs)) {
5672
+ throw new Error("Speech synthesis timed out because Retry-After exceeded the available retry budget.");
5673
+ }
5543
5674
  onRetry(attempt, delayMs);
5544
5675
  if (delayMs > 0)
5545
5676
  await new Promise((resolve, reject) => {
@@ -5587,20 +5718,24 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
5587
5718
  diagnostics: errors
5588
5719
  });
5589
5720
  }
5721
+ const jobScope = options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts.totalJobMs) : void 0;
5590
5722
  try {
5591
5723
  return {
5592
5724
  ok: true,
5593
5725
  success: true,
5594
5726
  status: "success",
5595
5727
  value: await client.synthesizeSsml(ssml, {
5596
- signal: options.signal,
5728
+ signal: jobScope?.signal ?? options.signal,
5597
5729
  timeoutMs: options.timeouts?.perChunkMs,
5598
5730
  timeouts: options.timeouts
5599
5731
  })
5600
5732
  };
5601
5733
  } catch (error) {
5734
+ if (jobScope?.timedOut()) return failure(toSynthesisError(new Error("Speech synthesis timed out.")));
5602
5735
  const synthesisError = toSynthesisError(error);
5603
5736
  return failure(synthesisError);
5737
+ } finally {
5738
+ jobScope?.dispose();
5604
5739
  }
5605
5740
  }
5606
5741
  async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
@@ -5672,17 +5807,32 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
5672
5807
  resumeChunkIndices: options.resumeChunkIndices,
5673
5808
  customMerger: options.customMerger,
5674
5809
  outputMimeType: options.outputMimeType,
5675
- postMergeValidator: options.postMergeValidator
5810
+ postMergeValidator: options.postMergeValidator,
5811
+ resumeValidation: options.resumeValidation
5676
5812
  });
5677
5813
  return { ok: true, success: true, status: "success", value };
5678
5814
  }
5815
+ const inputs = chunks.map((chunk) => typeof chunk === "string" ? { ssml: chunk } : chunk);
5816
+ const fingerprints = inputs.map((chunk) => computeChunkFingerprint(chunk.ssml, options.outputFormat));
5679
5817
  const results = new Array(chunks.length);
5818
+ const chunkStates = inputs.map((_chunk, chunkIndex) => ({
5819
+ chunkIndex,
5820
+ status: "pending",
5821
+ canResume: true
5822
+ }));
5680
5823
  const cachedChunks = new Map((options.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
5824
+ const invalidCachedIndices = /* @__PURE__ */ new Set();
5681
5825
  for (const [index, cached] of cachedChunks) {
5682
- if (index >= 0 && index < chunks.length) results[index] = cached;
5826
+ if (index < 0 || index >= chunks.length) continue;
5827
+ if (options.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index]) {
5828
+ results[index] = cached;
5829
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: cached };
5830
+ } else invalidCachedIndices.add(index);
5683
5831
  }
5684
5832
  const requestedIndices = options.resumeChunkIndices ? new Set(options.resumeChunkIndices.filter((index) => index >= 0 && index < chunks.length)) : void 0;
5685
- const shouldSynthesize = (index) => !cachedChunks.has(index) && (requestedIndices === void 0 || requestedIndices.has(index));
5833
+ const shouldSynthesize = (index) => (!cachedChunks.has(index) || invalidCachedIndices.has(index)) && (requestedIndices === void 0 || requestedIndices.has(index) || invalidCachedIndices.has(index));
5834
+ const jobStartedAt = Date.now();
5835
+ const jobDeadlineAt = options.timeouts?.totalJobMs !== void 0 && options.timeouts.totalJobMs > 0 ? jobStartedAt + options.timeouts.totalJobMs : void 0;
5686
5836
  const jobScope = chunks.length > 1 || options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs) : void 0;
5687
5837
  fallbackJobScope = jobScope;
5688
5838
  const failedIndices = /* @__PURE__ */ new Set();
@@ -5695,7 +5845,10 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
5695
5845
  const index = nextIndex++;
5696
5846
  if (index >= chunks.length) return;
5697
5847
  if (!shouldSynthesize(index)) continue;
5698
- if (failedIndices.size > 0 && options.cancelOnFailure !== false) return;
5848
+ if (firstError && options.cancelOnFailure !== false) {
5849
+ chunkStates[index] = { chunkIndex: index, status: "cancelled", isOriginalFailure: false, canResume: true };
5850
+ return;
5851
+ }
5699
5852
  const chunk = chunks[index];
5700
5853
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
5701
5854
  const sourceNodePath = input.sourceNodePath;
@@ -5728,7 +5881,8 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
5728
5881
  retryAttempt,
5729
5882
  nextRetryDelayMs,
5730
5883
  isRetrying: true
5731
- })
5884
+ }),
5885
+ jobDeadlineAt
5732
5886
  );
5733
5887
  } catch (error) {
5734
5888
  if (chunkScope?.timedOut())
@@ -5780,6 +5934,7 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
5780
5934
  }))
5781
5935
  } : {}
5782
5936
  };
5937
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
5783
5938
  completed += 1;
5784
5939
  options.onProgress?.({
5785
5940
  currentChunk: completed,
@@ -5791,7 +5946,16 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
5791
5946
  durationMs: Date.now() - startedAt
5792
5947
  });
5793
5948
  } catch (error) {
5794
- failedIndices.add(index);
5949
+ const wasCancelled = firstError !== void 0 || Boolean(jobScope?.signal.aborted && !jobScope?.timedOut());
5950
+ firstError ?? (firstError = error);
5951
+ if (!wasCancelled) failedIndices.add(index);
5952
+ chunkStates[index] = {
5953
+ chunkIndex: index,
5954
+ status: wasCancelled ? "cancelled" : "failed",
5955
+ isOriginalFailure: !wasCancelled,
5956
+ canResume: true,
5957
+ error
5958
+ };
5795
5959
  options.onProgress?.({
5796
5960
  currentChunk: completed,
5797
5961
  totalChunks: chunks.length,
@@ -5803,19 +5967,32 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
5803
5967
  error
5804
5968
  });
5805
5969
  if (options.cancelOnFailure !== false) jobScope?.abort();
5806
- firstError ?? (firstError = error);
5807
5970
  return;
5808
5971
  }
5809
5972
  }
5810
5973
  };
5811
5974
  await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
5975
+ if (firstError && options.cancelOnFailure !== false) {
5976
+ for (const [chunkIndex, state] of chunkStates.entries()) {
5977
+ if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
5978
+ chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
5979
+ }
5980
+ }
5981
+ }
5812
5982
  if (failedIndices.size > 0) {
5813
5983
  const error = firstError ?? new Error("One or more SSML chunks failed to synthesize.");
5984
+ const synthesizedChunks = results.flatMap(
5985
+ (result, chunkIndex) => result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : []
5986
+ );
5814
5987
  error.partialResult = {
5815
- synthesizedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
5816
- completedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
5817
- pendingChunkIndices: chunks.flatMap((_chunk, chunkIndex) => results[chunkIndex] ? [] : [chunkIndex]),
5988
+ synthesizedChunks,
5989
+ completedChunks: synthesizedChunks,
5990
+ pendingChunkIndices: chunkStates.flatMap(
5991
+ (state) => state.status === "pending" || state.status === "cancelled" || state.status === "failed" ? [state.chunkIndex] : []
5992
+ ),
5818
5993
  failedChunkIndices: [...failedIndices],
5994
+ cancelledChunkIndices: chunkStates.filter((state) => state.status === "cancelled").map((state) => state.chunkIndex),
5995
+ chunkStates,
5819
5996
  totalChunks: chunks.length
5820
5997
  };
5821
5998
  throw error;
@@ -5861,7 +6038,16 @@ var AzureTtsClient = class {
5861
6038
  const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
5862
6039
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
5863
6040
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
5864
- const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts };
6041
+ const config = {
6042
+ endpoint,
6043
+ region,
6044
+ subscriptionKey,
6045
+ outputFormat,
6046
+ signal,
6047
+ timeoutMs,
6048
+ timeouts,
6049
+ retryOptions: __privateGet(this, _options).retryOptions
6050
+ };
5865
6051
  return synthesizeSpeech(ssml, config);
5866
6052
  }
5867
6053
  async synthesizeSsml(ssml, options = {}) {
@@ -5878,7 +6064,13 @@ var AzureTtsClient = class {
5878
6064
  timeouts: options.timeouts ?? timeouts,
5879
6065
  sourceNodePath: options.sourceNodePath,
5880
6066
  sourceTextSegments: options.sourceTextSegments,
5881
- sourceMarkers: options.sourceMarkers
6067
+ sourceMarkers: options.sourceMarkers,
6068
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
6069
+ cancelOnFailure: options.cancelOnFailure ?? __privateGet(this, _options).cancelOnFailure,
6070
+ customMerger: options.customMerger ?? __privateGet(this, _options).customMerger,
6071
+ outputMimeType: options.outputMimeType ?? __privateGet(this, _options).outputMimeType,
6072
+ postMergeValidator: options.postMergeValidator ?? __privateGet(this, _options).postMergeValidator,
6073
+ resumeValidation: options.resumeValidation ?? __privateGet(this, _options).resumeValidation
5882
6074
  });
5883
6075
  }
5884
6076
  async synthesizeChunks(chunks, options = {}) {
@@ -5896,12 +6088,13 @@ var AzureTtsClient = class {
5896
6088
  onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
5897
6089
  concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
5898
6090
  retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
5899
- cancelOnFailure: options.cancelOnFailure,
6091
+ cancelOnFailure: options.cancelOnFailure ?? __privateGet(this, _options).cancelOnFailure,
5900
6092
  resumeChunks: options.resumeChunks,
5901
6093
  resumeChunkIndices: options.resumeChunkIndices,
5902
- customMerger: options.customMerger,
5903
- outputMimeType: options.outputMimeType,
5904
- postMergeValidator: options.postMergeValidator
6094
+ customMerger: options.customMerger ?? __privateGet(this, _options).customMerger,
6095
+ outputMimeType: options.outputMimeType ?? __privateGet(this, _options).outputMimeType,
6096
+ postMergeValidator: options.postMergeValidator ?? __privateGet(this, _options).postMergeValidator,
6097
+ resumeValidation: options.resumeValidation ?? __privateGet(this, _options).resumeValidation
5905
6098
  });
5906
6099
  }
5907
6100
  async synthesizeSsmlSafe(ssml, options = {}) {
@@ -6030,6 +6223,7 @@ async function fetchAzureVoiceCatalog(options) {
6030
6223
  buildPartialSsml,
6031
6224
  buildSsml,
6032
6225
  canMergeAudioFormat,
6226
+ computeChunkFingerprint,
6033
6227
  createAzureUrlValidatorRunner,
6034
6228
  extractSsmlText,
6035
6229
  extractSsmlTranslatableText,