ssml-builder-js 2.15.0 → 2.17.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
@@ -15,13 +15,15 @@ import {
15
15
  parseSsml,
16
16
  splitSsmlDocument,
17
17
  validateAzureSsml,
18
+ validateAzureSsmlChunks,
18
19
  validateSsml,
19
20
  validateSsmlStructureIntegrity
20
- } from "./chunk-FXUM45ZY.mjs";
21
+ } from "./chunk-2SYLELUT.mjs";
21
22
  import {
23
+ createAzureUrlValidatorRunner as createAzureUrlValidatorRunner2,
22
24
  getSsmlSourceMap as getSsmlSourceMap2,
23
25
  validateAzureSsml as validateAzureSsml2
24
- } from "./chunk-WXFLUCLR.mjs";
26
+ } from "./chunk-5AZWURHW.mjs";
25
27
  import {
26
28
  __privateAdd,
27
29
  __privateGet,
@@ -30,7 +32,7 @@ import {
30
32
 
31
33
  // packages/azure-tts-client/src/errors.ts
32
34
  var AzureTtsError = class extends Error {
33
- constructor(status, statusText, responseBody, requestId) {
35
+ constructor(status, statusText, responseBody, requestId, responseHeaders) {
34
36
  super(`Azure TTS request failed: ${status} ${statusText}`);
35
37
  this.kind = "azure-api-error";
36
38
  this.name = "AzureTtsError";
@@ -38,8 +40,37 @@ var AzureTtsError = class extends Error {
38
40
  this.statusText = statusText;
39
41
  this.responseBody = responseBody;
40
42
  this.requestId = requestId;
43
+ const value = responseHeaders instanceof Headers ? responseHeaders.get("retry-after") : responseHeaders?.["retry-after"] ?? responseHeaders?.["Retry-After"];
44
+ const seconds = value ? Number(value.trim()) : NaN;
45
+ const date = value ? Date.parse(value) : NaN;
46
+ if (Number.isFinite(seconds) && seconds >= 0) this.retryAfterMs = seconds * 1e3;
47
+ else if (Number.isFinite(date)) this.retryAfterMs = Math.max(0, date - Date.now());
41
48
  }
42
49
  };
50
+ function getRetryAfterDelayMs(error) {
51
+ if (error instanceof AzureTtsError && error.retryAfterMs !== void 0) return error.retryAfterMs;
52
+ if (!error || typeof error !== "object") return void 0;
53
+ const candidate = error;
54
+ if (typeof candidate.retryAfterMs === "number" && candidate.retryAfterMs >= 0) return candidate.retryAfterMs;
55
+ const headers = candidate.headers ?? candidate.response?.headers;
56
+ if (headers instanceof Headers) {
57
+ const value = headers.get("retry-after");
58
+ if (!value) return void 0;
59
+ const seconds = Number(value.trim());
60
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
61
+ const date = Date.parse(value);
62
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : void 0;
63
+ }
64
+ if (headers && typeof headers === "object") {
65
+ const value = headers["retry-after"] ?? headers["Retry-After"];
66
+ if (typeof value !== "string") return void 0;
67
+ const seconds = Number(value.trim());
68
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
69
+ const date = Date.parse(value);
70
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : void 0;
71
+ }
72
+ return void 0;
73
+ }
43
74
  var AzureTtsSdkError = class extends AzureTtsError {
44
75
  constructor(errorDetails) {
45
76
  super(0, "Speech SDK", errorDetails, null);
@@ -70,6 +101,14 @@ var MergeError = class extends Error {
70
101
  this.cause = cause;
71
102
  }
72
103
  };
104
+ var AudioFormatMismatchError = class extends Error {
105
+ constructor(message, inputSpecs = []) {
106
+ super(message);
107
+ this.kind = "audio-format-mismatch";
108
+ this.name = "AudioFormatMismatchError";
109
+ this.inputSpecs = inputSpecs;
110
+ }
111
+ };
73
112
  var UnsupportedMergeFormatError = class extends Error {
74
113
  constructor(format) {
75
114
  super(`Audio format "${format}" cannot be safely concatenated; container re-multiplexing is required.`);
@@ -79,7 +118,7 @@ var UnsupportedMergeFormatError = class extends Error {
79
118
  }
80
119
  };
81
120
  function toSynthesisError(error) {
82
- if (error instanceof AzureTtsError || error instanceof MergeError || error instanceof UnsupportedMergeFormatError || error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError)
121
+ if (error instanceof AzureTtsError || error instanceof MergeError || error instanceof AudioFormatMismatchError || error instanceof UnsupportedMergeFormatError || error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError)
83
122
  return error;
84
123
  const message = error instanceof Error ? error.message : String(error);
85
124
  if (/cancel|abort/i.test(message)) return new SynthesisCancelledError(message);
@@ -208,6 +247,115 @@ function parseWav(buffer) {
208
247
  }
209
248
  return { chunks, data, format };
210
249
  }
250
+ function formatNumber(format, pattern, fallback) {
251
+ const match = pattern.exec(format);
252
+ return match?.[1] ? Number(match[1]) : fallback;
253
+ }
254
+ function formatChannels(format, fallback) {
255
+ if (/stereo|2ch|dual/i.test(format)) return 2;
256
+ if (/mono|1ch/i.test(format)) return 1;
257
+ return fallback;
258
+ }
259
+ function formatAudioSpecification(format) {
260
+ const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
261
+ const channels = formatChannels(format, 0);
262
+ const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
263
+ 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";
265
+ const bitDepthMatch = /(\d+)bit/i.exec(format);
266
+ 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
+ return {
268
+ format,
269
+ mimeType: resolveMimeType(format),
270
+ codec,
271
+ sampleRate,
272
+ channels,
273
+ ...bitrate ? { bitrate } : {},
274
+ ...bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {},
275
+ ...container ? { container } : {},
276
+ isVbr: /vbr/i.test(format),
277
+ isCompressed: codec === "mp3" || codec === "opus" || codec === "silk"
278
+ };
279
+ }
280
+ function parseMp3Specification(buffer, format) {
281
+ const bytes = stripMp3Tags(buffer);
282
+ const bitrates = [
283
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
284
+ [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0],
285
+ [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0]
286
+ ];
287
+ const sampleRates = [
288
+ [44100, 48e3, 32e3],
289
+ [22050, 24e3, 16e3],
290
+ [11025, 12e3, 8e3]
291
+ ];
292
+ for (let index = 0; index + 4 <= bytes.length; index += 1) {
293
+ if (bytes[index] !== 255 || (bytes[index + 1] ?? 0) < 224) continue;
294
+ const header = bytes[index + 1] ?? 0;
295
+ const versionBits = header >> 3 & 3;
296
+ const layer = header >> 1 & 3;
297
+ const bitrateIndex = (bytes[index + 2] ?? 0) >> 4;
298
+ const sampleIndex = (bytes[index + 2] ?? 0) >> 2 & 3;
299
+ if (versionBits === 1 || layer !== 1 || bitrateIndex === 0 || bitrateIndex === 15 || sampleIndex === 3) continue;
300
+ const versionIndex = versionBits === 3 ? 0 : versionBits === 2 ? 1 : 2;
301
+ const bitrateTable = versionBits === 3 ? bitrates[1] : bitrates[2];
302
+ const sampleRate = sampleRates[versionIndex]?.[sampleIndex] ?? 0;
303
+ const bitrateKbps = bitrateTable?.[bitrateIndex] ?? 0;
304
+ if (!sampleRate || !bitrateKbps) continue;
305
+ return {
306
+ format,
307
+ mimeType: "audio/mpeg",
308
+ codec: "mp3",
309
+ sampleRate,
310
+ channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
311
+ bitrate: bitrateKbps * 1e3,
312
+ container: "mp3-raw",
313
+ isVbr: false,
314
+ isCompressed: true
315
+ };
316
+ }
317
+ return void 0;
318
+ }
319
+ function inspectAudioSpecification(buffer, format) {
320
+ if (isWavFormat(format) || ascii(new Uint8Array(buffer), 0, "RIFF")) {
321
+ const parsed = parseWav(buffer);
322
+ if (parsed.format.byteLength < 16) throw new Error("Invalid WAV fmt chunk.");
323
+ const view = new DataView(parsed.format.buffer, parsed.format.byteOffset, parsed.format.byteLength);
324
+ const sampleRate = view.getUint32(4, true);
325
+ const channels = view.getUint16(2, true);
326
+ const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
327
+ const formatCode = view.getUint16(0, true);
328
+ return {
329
+ format,
330
+ mimeType: "audio/wav",
331
+ codec: formatCode === 1 ? "pcm" : "unknown",
332
+ sampleRate,
333
+ channels,
334
+ ...sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {},
335
+ bitDepth: bitsPerSample,
336
+ container: "riff-wave",
337
+ isVbr: false,
338
+ isCompressed: formatCode !== 1
339
+ };
340
+ }
341
+ if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
342
+ return formatAudioSpecification(format);
343
+ }
344
+ function validateAudioSpecifications(specs) {
345
+ const first = specs[0];
346
+ if (!first) return;
347
+ 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
349
+ );
350
+ if (mismatch)
351
+ throw new AudioFormatMismatchError(
352
+ `Audio chunks have incompatible specifications: ${first.sampleRate}Hz/${first.channels}ch versus ${mismatch.sampleRate}Hz/${mismatch.channels}ch.`,
353
+ specs
354
+ );
355
+ }
356
+ function isAudioFormatMismatch(error) {
357
+ return error instanceof AudioFormatMismatchError || error !== null && typeof error === "object" && "kind" in error && error.kind === "audio-format-mismatch";
358
+ }
211
359
  function writeUint32(target, offset, value) {
212
360
  new DataView(target.buffer).setUint32(offset, value, true);
213
361
  }
@@ -293,6 +441,7 @@ function mergeAudioBuffers(buffers, options) {
293
441
  const format = typeof options === "string" ? options : options?.format;
294
442
  if (!format) throw new UnsupportedMergeFormatError("");
295
443
  try {
444
+ validateAudioSpecifications(buffers.map((buffer) => inspectAudioSpecification(buffer, format)));
296
445
  if (isWavFormat(format)) return mergeWavBuffers(buffers);
297
446
  if (isMp3Format(format)) {
298
447
  const parts = buffers.map(stripMp3Tags);
@@ -315,7 +464,8 @@ function mergeAudioBuffers(buffers, options) {
315
464
  }
316
465
  throw new UnsupportedMergeFormatError(format);
317
466
  } catch (error) {
318
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
467
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
468
+ throw error;
319
469
  throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
320
470
  }
321
471
  }
@@ -393,17 +543,24 @@ async function synthesizeSsml(ssml, config) {
393
543
  return {
394
544
  originalTextRange: { ...marker.originalTextRange },
395
545
  sourceNodePath: [...marker.sourceNodePath],
396
- textRange: { ...marker.originalTextRange }
546
+ textRange: { ...marker.originalTextRange },
547
+ mappingStatus: "exact"
397
548
  };
398
549
  }
399
- if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) return {};
550
+ if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) {
551
+ return { mappingStatus: "unmapped" };
552
+ }
400
553
  const value = text ?? "";
401
554
  let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? offsetHint : -1;
402
- if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value)
555
+ let mappingStatus = "exact";
556
+ if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value) {
403
557
  localStart = -1;
558
+ mappingStatus = "fallback";
559
+ }
404
560
  if (localStart < 0 || localStart > sourceText.length) {
405
561
  localStart = value ? sourceText.indexOf(value, sourceEventCursor) : sourceEventCursor;
406
562
  if (localStart < 0) localStart = value ? sourceText.indexOf(value) : sourceEventCursor;
563
+ mappingStatus = "fallback";
407
564
  }
408
565
  localStart = Math.max(0, localStart);
409
566
  const localEnd = Math.min(sourceText.length, localStart + value.length);
@@ -414,7 +571,8 @@ async function synthesizeSsml(ssml, config) {
414
571
  return {
415
572
  originalTextRange: { ...fallbackRange },
416
573
  textRange: { ...fallbackRange },
417
- ...segment ? { sourceNodePath: [...segment.sourceNodePath] } : config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {}
574
+ ...segment ? { sourceNodePath: [...segment.sourceNodePath] } : config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
575
+ mappingStatus: segment || config.sourceTextRange || config.sourceNodePath ? mappingStatus : "unmapped"
418
576
  };
419
577
  };
420
578
  synthesizer.wordBoundary = (_sender, event) => {
@@ -463,20 +621,32 @@ async function synthesizeSsml(ssml, config) {
463
621
  );
464
622
  const durationMs = result.audioDuration ? ticksToMilliseconds(result.audioDuration) : eventDurationMs;
465
623
  const requestId = result.resultId;
466
- const addSourceMetadata = (event) => ({
467
- ...event,
468
- ...config.sourceTextRange && !("textRange" in event) ? { textRange: { ...config.sourceTextRange } } : {},
469
- ...config.sourceTextRange && !("originalTextRange" in event) ? { originalTextRange: { ...config.sourceTextRange } } : {},
470
- ...config.chunkIndex !== void 0 ? { chunkIndex: config.chunkIndex } : {},
471
- ...config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
472
- ...requestId ? { requestId } : {}
473
- });
624
+ const addSourceMetadata = (event) => {
625
+ const mapped = {
626
+ ...event,
627
+ ...config.sourceTextRange && !("textRange" in event) ? { textRange: { ...config.sourceTextRange } } : {},
628
+ ...config.sourceTextRange && !("originalTextRange" in event) ? { originalTextRange: { ...config.sourceTextRange } } : {},
629
+ ...config.chunkIndex !== void 0 ? { chunkIndex: config.chunkIndex } : {},
630
+ ...config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
631
+ ...requestId ? { requestId } : {}
632
+ };
633
+ if (event.mappingStatus === "unmapped") {
634
+ Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
635
+ Object.defineProperty(mapped, "toJSON", {
636
+ value: () => ({ ...mapped, mappingStatus: "unmapped" }),
637
+ enumerable: false
638
+ });
639
+ }
640
+ return mapped;
641
+ };
474
642
  const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
475
643
  const sourceVisemes = visemes.map((viseme) => addSourceMetadata(viseme));
476
644
  const sourceBookmarks = bookmarks.map((bookmark) => addSourceMetadata(bookmark));
477
645
  resolve({
478
646
  audioData: result.audioData,
479
647
  durationMs,
648
+ audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
649
+ mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
480
650
  ...config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {},
481
651
  ...requestId ? { requestId } : {},
482
652
  ...sourceBoundaries.length > 0 ? { boundaries: sourceBoundaries, wordBoundary: sourceBoundaries, wordBoundaries: sourceBoundaries } : {},
@@ -489,10 +659,11 @@ async function synthesizeSsml(ssml, config) {
489
659
  abortHandler = () => rejectWithError(new SynthesisCancelledError());
490
660
  config.signal.addEventListener("abort", abortHandler, { once: true });
491
661
  }
492
- if (config.timeoutMs !== void 0 && config.timeoutMs > 0) {
662
+ const timeoutMs = config.timeouts?.perChunkMs ?? config.timeoutMs;
663
+ if (timeoutMs !== void 0 && timeoutMs > 0) {
493
664
  timeout = setTimeout(
494
- () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeoutMs} ms.`)),
495
- config.timeoutMs
665
+ () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
666
+ timeoutMs
496
667
  );
497
668
  }
498
669
  synthesizer.speakSsmlAsync(ssml, cb, rejectWithError);
@@ -501,9 +672,109 @@ async function synthesizeSsml(ssml, config) {
501
672
  }
502
673
  });
503
674
  }
675
+ function isRetryableSynthesisError(error) {
676
+ if (error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError) return false;
677
+ if (error instanceof AzureTtsError && error.status !== 0)
678
+ return error.status === 429 || error.status >= 500 && error.status < 600;
679
+ const message = error instanceof Error ? error.message : String(error);
680
+ if (/\b4\d{2}\b/.test(message)) return false;
681
+ const status = error && typeof error === "object" && "status" in error ? error.status : void 0;
682
+ if (typeof status === "number") return status === 429 || status >= 500 && status < 600;
683
+ return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
684
+ }
685
+ function retryDelay(options, retryAttempt, error) {
686
+ const retryAfterMs = getRetryAfterDelayMs(error);
687
+ if (retryAfterMs !== void 0) return retryAfterMs;
688
+ const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
689
+ return Math.floor(Math.random() * (base + 1));
690
+ }
691
+ function resolveConcurrency(value, total) {
692
+ if (value === void 0) return 1;
693
+ if (value === Infinity) return Math.max(1, total);
694
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
695
+ }
696
+ async function waitForRetry(delayMs, signal) {
697
+ if (signal?.aborted) throw new SynthesisCancelledError();
698
+ if (delayMs <= 0) return;
699
+ await new Promise((resolve, reject) => {
700
+ let timer;
701
+ const abort = () => {
702
+ clearTimeout(timer);
703
+ signal?.removeEventListener("abort", abort);
704
+ reject(new SynthesisCancelledError());
705
+ };
706
+ timer = setTimeout(() => {
707
+ signal?.removeEventListener("abort", abort);
708
+ resolve();
709
+ }, delayMs);
710
+ if (signal) {
711
+ signal.addEventListener("abort", abort, { once: true });
712
+ }
713
+ });
714
+ }
715
+ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
716
+ const options = retryOptions ? {
717
+ maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
718
+ initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
719
+ maxDelayMs: Math.max(0, retryOptions.maxDelayMs),
720
+ shouldRetry: retryOptions.shouldRetry
721
+ } : void 0;
722
+ let attempt = 0;
723
+ while (true) {
724
+ if (config.signal?.aborted) throw new SynthesisCancelledError();
725
+ try {
726
+ return await synthesizeSsml(ssml, config);
727
+ } catch (error) {
728
+ if (!options || attempt >= options.maxRetries || !(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error)))
729
+ throw error;
730
+ attempt += 1;
731
+ const delayMs = retryDelay(options, attempt, error);
732
+ onRetry(attempt, delayMs);
733
+ await waitForRetry(delayMs, config.signal);
734
+ }
735
+ }
736
+ }
737
+ function createAbortScope(parent, timeoutMs) {
738
+ const controller = new AbortController();
739
+ let didTimeout = false;
740
+ const onAbort = () => controller.abort();
741
+ if (parent?.aborted) controller.abort();
742
+ parent?.addEventListener("abort", onAbort, { once: true });
743
+ const timer = timeoutMs !== void 0 && timeoutMs > 0 ? setTimeout(() => {
744
+ didTimeout = true;
745
+ controller.abort();
746
+ }, timeoutMs) : void 0;
747
+ return {
748
+ signal: controller.signal,
749
+ timedOut: () => didTimeout,
750
+ dispose: () => {
751
+ if (timer) clearTimeout(timer);
752
+ parent?.removeEventListener("abort", onAbort);
753
+ },
754
+ abort: () => controller.abort()
755
+ };
756
+ }
757
+ async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs, onRetry) {
758
+ const scope = createAbortScope(config.signal, timeoutMs);
759
+ try {
760
+ return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry);
761
+ } catch (error) {
762
+ if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
763
+ throw error;
764
+ } finally {
765
+ scope.dispose();
766
+ }
767
+ }
504
768
  async function synthesizeSsmlChunks(chunks, config) {
505
- const results = [];
769
+ const results = new Array(chunks.length);
506
770
  const totalChunks = chunks.length;
771
+ const cachedChunks = new Map((config.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
772
+ for (const [index, cached] of cachedChunks) {
773
+ if (index >= 0 && index < totalChunks) results[index] = cached;
774
+ }
775
+ 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));
777
+ const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
507
778
  const report = (event) => config.onProgress?.(event);
508
779
  for (const [index, chunk] of chunks.entries()) {
509
780
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
@@ -517,57 +788,112 @@ async function synthesizeSsmlChunks(chunks, config) {
517
788
  durationMs: 0
518
789
  });
519
790
  }
520
- for (const [index, chunk] of chunks.entries()) {
521
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
522
- report({
523
- currentChunk: index,
524
- totalChunks,
525
- percent: totalChunks === 0 ? 100 : Math.round(index / totalChunks * 100),
526
- chunkIndex: index,
527
- originalTextRange: input.originalTextRange,
528
- status: "synthesizing",
529
- durationMs: 0
530
- });
531
- const startedAt = Date.now();
532
- try {
533
- const result = await synthesizeSsml(input.ssml, {
534
- ...config,
535
- ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
536
- ...input.sourceNodePath ?? config.sourceNodePath ? { sourceNodePath: [...input.sourceNodePath ?? config.sourceNodePath ?? []] } : {},
537
- ...input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {},
538
- ...input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {},
539
- chunkIndex: index,
540
- onProgress: void 0
541
- });
542
- results.push(result);
543
- report({
544
- currentChunk: index + 1,
545
- totalChunks,
546
- percent: totalChunks === 0 ? 100 : Math.round((index + 1) / totalChunks * 100),
547
- chunkIndex: index,
548
- originalTextRange: input.originalTextRange,
549
- status: "success",
550
- durationMs: Date.now() - startedAt
551
- });
552
- } catch (error) {
791
+ let completed = [...results].filter((result) => result !== void 0).length;
792
+ let nextIndex = 0;
793
+ const concurrency = resolveConcurrency(config.concurrency, chunks.length);
794
+ let firstError;
795
+ const failedIndices = /* @__PURE__ */ new Set();
796
+ const worker = async () => {
797
+ while (true) {
798
+ const index = nextIndex++;
799
+ if (index >= chunks.length) return;
800
+ if (!shouldSynthesize(index)) continue;
801
+ if (firstError && config.cancelOnFailure !== false) return;
802
+ const chunk = chunks[index];
803
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
553
804
  report({
554
- currentChunk: index,
805
+ currentChunk: completed,
555
806
  totalChunks,
556
- percent: totalChunks === 0 ? 100 : Math.round(index / totalChunks * 100),
807
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
557
808
  chunkIndex: index,
558
809
  originalTextRange: input.originalTextRange,
559
- status: "failed",
560
- durationMs: Date.now() - startedAt,
561
- error
810
+ status: "synthesizing",
811
+ durationMs: 0
562
812
  });
563
- throw error;
813
+ const startedAt = Date.now();
814
+ try {
815
+ const result = await synthesizeChunkWithTimeout(
816
+ input.ssml,
817
+ {
818
+ ...config,
819
+ signal: scope.signal,
820
+ ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
821
+ ...input.sourceNodePath ?? config.sourceNodePath ? { sourceNodePath: [...input.sourceNodePath ?? config.sourceNodePath ?? []] } : {},
822
+ ...input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {},
823
+ ...input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {},
824
+ chunkIndex: index,
825
+ onProgress: void 0
826
+ },
827
+ config.retryOptions,
828
+ config.timeouts?.chunkWithRetriesMs ?? config.timeouts?.perChunkMs ?? config.timeoutMs,
829
+ (retryAttempt, nextRetryDelayMs) => report({
830
+ currentChunk: completed,
831
+ totalChunks,
832
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
833
+ chunkIndex: index,
834
+ originalTextRange: input.originalTextRange,
835
+ status: "synthesizing",
836
+ durationMs: Date.now() - startedAt,
837
+ retryAttempt,
838
+ nextRetryDelayMs,
839
+ isRetrying: true
840
+ })
841
+ );
842
+ results[index] = result;
843
+ completed += 1;
844
+ report({
845
+ currentChunk: completed,
846
+ totalChunks,
847
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
848
+ chunkIndex: index,
849
+ originalTextRange: input.originalTextRange,
850
+ status: "success",
851
+ durationMs: Date.now() - startedAt
852
+ });
853
+ } catch (error) {
854
+ failedIndices.add(index);
855
+ report({
856
+ currentChunk: completed,
857
+ totalChunks,
858
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
859
+ chunkIndex: index,
860
+ originalTextRange: input.originalTextRange,
861
+ status: "failed",
862
+ durationMs: Date.now() - startedAt,
863
+ error
864
+ });
865
+ firstError ?? (firstError = scope.timedOut() ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`) : error);
866
+ if (config.cancelOnFailure !== false) scope.abort();
867
+ return;
868
+ }
564
869
  }
870
+ };
871
+ try {
872
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
873
+ if (firstError) throw firstError;
874
+ const orderedResults = results.filter((result) => result !== void 0);
875
+ return await mergeSynthesisResults(orderedResults, {
876
+ format: config.outputFormat ?? DEFAULT_OUTPUT_FORMAT,
877
+ signal: scope.signal,
878
+ customMerger: config.customMerger,
879
+ outputMimeType: config.outputMimeType,
880
+ postMergeValidator: config.postMergeValidator
881
+ });
882
+ } catch (error) {
883
+ 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]),
887
+ failedChunkIndices: [...failedIndices],
888
+ totalChunks
889
+ };
890
+ if (error && typeof error === "object") error.partialResult = partial;
891
+ throw error;
892
+ } finally {
893
+ scope.dispose();
565
894
  }
566
- return mergeSynthesisResults(results, {
567
- format: config.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3"
568
- });
569
895
  }
570
- function createMergedResult(results, audioData, format) {
896
+ function createMergedResult(results, audioData, format, audioSpec, outputMimeType) {
571
897
  const boundaries = [];
572
898
  const visemes = [];
573
899
  const bookmarks = [];
@@ -586,7 +912,8 @@ function createMergedResult(results, audioData, format) {
586
912
  ...boundary.sourceNodePath ? { sourceNodePath: [...boundary.sourceNodePath] } : {},
587
913
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
588
914
  ...textRange ? { textRange: { ...textRange } } : {},
589
- ...requestId ? { requestId } : {}
915
+ ...requestId ? { requestId } : {},
916
+ mappingStatus: boundary.mappingStatus ?? "unmapped"
590
917
  });
591
918
  }
592
919
  for (const viseme of result.visemes ?? []) {
@@ -601,7 +928,8 @@ function createMergedResult(results, audioData, format) {
601
928
  ...viseme.sourceNodePath ? { sourceNodePath: [...viseme.sourceNodePath] } : {},
602
929
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
603
930
  ...textRange ? { textRange: { ...textRange } } : {},
604
- ...requestId ? { requestId } : {}
931
+ ...requestId ? { requestId } : {},
932
+ mappingStatus: viseme.mappingStatus ?? "unmapped"
605
933
  });
606
934
  }
607
935
  for (const bookmark of result.bookmarks ?? []) {
@@ -616,7 +944,8 @@ function createMergedResult(results, audioData, format) {
616
944
  ...bookmark.sourceNodePath ? { sourceNodePath: [...bookmark.sourceNodePath] } : {},
617
945
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
618
946
  ...textRange ? { textRange: { ...textRange } } : {},
619
- ...requestId ? { requestId } : {}
947
+ ...requestId ? { requestId } : {},
948
+ mappingStatus: bookmark.mappingStatus ?? "unmapped"
620
949
  });
621
950
  }
622
951
  durationOffset += Math.max(0, result.durationMs);
@@ -625,6 +954,8 @@ function createMergedResult(results, audioData, format) {
625
954
  audioData,
626
955
  durationMs: durationOffset,
627
956
  mimeType: resolveMimeType(format),
957
+ audioSpec: audioSpec ?? formatAudioSpecification(format),
958
+ ...outputMimeType ? { mimeType: outputMimeType } : {},
628
959
  ...boundaries.length > 0 ? { boundaries, wordBoundary: boundaries, wordBoundaries: boundaries } : {},
629
960
  ...visemes.length > 0 ? { visemes } : {},
630
961
  ...bookmarks.length > 0 ? { bookmarks } : {},
@@ -637,19 +968,73 @@ function mergeSynthesisResults(results, options) {
637
968
  const format = resolvedOptions?.format;
638
969
  if (!format) throw new UnsupportedMergeFormatError("");
639
970
  const buffers = results.map((result) => result.audioData);
971
+ const inputSpecs = results.map((result) => result.audioSpec ?? inspectAudioSpecification(result.audioData, format));
972
+ validateAudioSpecifications(inputSpecs);
973
+ const signal = resolvedOptions.signal ?? new AbortController().signal;
974
+ if (signal.aborted) throw new SynthesisCancelledError();
640
975
  if (resolvedOptions.customMerger) {
641
- return Promise.resolve().then(() => resolvedOptions.customMerger?.(buffers, format)).then((merged) => {
976
+ return Promise.resolve().then(
977
+ () => resolvedOptions.customMerger?.(buffers, {
978
+ format,
979
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
980
+ inputSpecs,
981
+ signal
982
+ })
983
+ ).then((merged) => {
642
984
  if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
643
- return createMergedResult(results, merged, format);
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
+ if (signal.aborted) throw new SynthesisCancelledError();
988
+ const result = createMergedResult(
989
+ results,
990
+ merged,
991
+ format,
992
+ inspectAudioSpecification(merged, format),
993
+ resolvedOptions.outputMimeType
994
+ );
995
+ return Promise.resolve(
996
+ resolvedOptions.postMergeValidator?.(result, {
997
+ format,
998
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
999
+ inputSpecs,
1000
+ signal
1001
+ })
1002
+ ).then((valid) => {
1003
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1004
+ return result;
1005
+ });
644
1006
  }).catch((error) => {
645
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
1007
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
1008
+ throw error;
646
1009
  throw new MergeError(`Custom audio merger failed for format "${format}".`, error);
647
1010
  });
648
1011
  }
649
1012
  try {
650
- return createMergedResult(results, mergeAudioBuffers(buffers, { format }), format);
1013
+ const result = createMergedResult(
1014
+ results,
1015
+ mergeAudioBuffers(buffers, { format }),
1016
+ format,
1017
+ inputSpecs[0],
1018
+ resolvedOptions.outputMimeType
1019
+ );
1020
+ if (resolvedOptions.postMergeValidator) {
1021
+ const validation = resolvedOptions.postMergeValidator(result, {
1022
+ format,
1023
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
1024
+ inputSpecs,
1025
+ signal
1026
+ });
1027
+ if (validation instanceof Promise)
1028
+ return validation.then((valid) => {
1029
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1030
+ return result;
1031
+ });
1032
+ if (validation === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1033
+ }
1034
+ return result;
651
1035
  } catch (error) {
652
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
1036
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
1037
+ throw error;
653
1038
  throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
654
1039
  }
655
1040
  }
@@ -667,11 +1052,125 @@ var ChunkValidationError = class extends Error {
667
1052
  this.diagnostics = diagnostics;
668
1053
  }
669
1054
  };
670
- function failure(error) {
671
- return { ok: false, success: false, status: error.kind, error };
1055
+ var BatchChunkValidationError = class extends ChunkValidationError {
1056
+ constructor(chunkDiagnostics) {
1057
+ const first = chunkDiagnostics[0];
1058
+ super(first?.chunkIndex ?? -1, first?.diagnostics ?? []);
1059
+ this.name = "BatchChunkValidationError";
1060
+ this.message = `SSML validation failed for ${chunkDiagnostics.length} chunk(s); the Azure Speech API was not called.`;
1061
+ this.chunkDiagnostics = chunkDiagnostics;
1062
+ this.totalErrorCount = chunkDiagnostics.reduce((total, chunk) => total + chunk.diagnostics.length, 0);
1063
+ this.errorCount = this.totalErrorCount;
1064
+ this.totalErrors = this.totalErrorCount;
1065
+ }
1066
+ };
1067
+ function failure(error, partialResult) {
1068
+ return {
1069
+ ok: false,
1070
+ success: false,
1071
+ status: error.kind,
1072
+ error,
1073
+ ...partialResult ? { partialResult } : {}
1074
+ };
1075
+ }
1076
+ function partialResultFrom(error) {
1077
+ if (!error || typeof error !== "object") return void 0;
1078
+ const partial = error.partialResult;
1079
+ if (!partial || typeof partial !== "object") return void 0;
1080
+ return partial;
1081
+ }
1082
+ function createSafeAbortScope(parent, timeoutMs) {
1083
+ const controller = new AbortController();
1084
+ let didTimeout = false;
1085
+ const onAbort = () => controller.abort();
1086
+ if (parent?.aborted) controller.abort();
1087
+ parent?.addEventListener("abort", onAbort, { once: true });
1088
+ const timer = timeoutMs !== void 0 && timeoutMs > 0 ? setTimeout(() => {
1089
+ didTimeout = true;
1090
+ controller.abort();
1091
+ }, timeoutMs) : void 0;
1092
+ return {
1093
+ signal: controller.signal,
1094
+ timedOut: () => didTimeout,
1095
+ dispose: () => {
1096
+ if (timer) clearTimeout(timer);
1097
+ parent?.removeEventListener("abort", onAbort);
1098
+ },
1099
+ abort: () => controller.abort()
1100
+ };
1101
+ }
1102
+ function isRetryable(error) {
1103
+ if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
1104
+ const status = error && typeof error === "object" && "status" in error ? error.status : void 0;
1105
+ if (typeof status === "number" && status !== 0) return status === 429 || status >= 500 && status < 600;
1106
+ const message = error instanceof Error ? error.message : String(error);
1107
+ if (/\b4\d{2}\b/.test(message)) return false;
1108
+ return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
1109
+ }
1110
+ function delayForRetry(options, attempt) {
1111
+ const maxDelay = Math.max(0, options.maxDelayMs);
1112
+ const base = Math.min(maxDelay, Math.max(0, options.initialDelayMs) * 2 ** Math.max(0, attempt - 1));
1113
+ return Math.floor(Math.random() * (base + 1));
1114
+ }
1115
+ function retryDelayForError(options, attempt, error) {
1116
+ return getRetryAfterDelayMs(error) ?? delayForRetry(options, attempt);
1117
+ }
1118
+ function resolveConcurrency2(value, total) {
1119
+ if (value === void 0) return 1;
1120
+ if (value === Infinity) return Math.max(1, total);
1121
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
1122
+ }
1123
+ async function retryableSynthesis(synthesize, options, signal, onRetry) {
1124
+ const retry = options ? {
1125
+ maxRetries: Math.max(0, Math.floor(options.maxRetries)),
1126
+ initialDelayMs: options.initialDelayMs,
1127
+ maxDelayMs: options.maxDelayMs,
1128
+ shouldRetry: options.shouldRetry
1129
+ } : void 0;
1130
+ let attempt = 0;
1131
+ while (true) {
1132
+ if (signal?.aborted) throw new Error("Speech synthesis was cancelled.");
1133
+ try {
1134
+ return await synthesize();
1135
+ } catch (error) {
1136
+ if (!retry || attempt >= retry.maxRetries || !(retry.shouldRetry?.(error, attempt + 1) ?? isRetryable(error)))
1137
+ throw error;
1138
+ attempt += 1;
1139
+ const delayMs = retryDelayForError(retry, attempt, error);
1140
+ onRetry(attempt, delayMs);
1141
+ if (delayMs > 0)
1142
+ await new Promise((resolve, reject) => {
1143
+ const timer = setTimeout(() => {
1144
+ signal?.removeEventListener("abort", abort);
1145
+ resolve();
1146
+ }, delayMs);
1147
+ const abort = () => {
1148
+ clearTimeout(timer);
1149
+ signal?.removeEventListener("abort", abort);
1150
+ reject(new Error("Speech synthesis was cancelled."));
1151
+ };
1152
+ signal?.addEventListener("abort", abort, { once: true });
1153
+ });
1154
+ }
1155
+ }
1156
+ }
1157
+ function sharedValidationOptions(options, signal) {
1158
+ const validator = options.urlValidator ?? options.customUrlValidator;
1159
+ if (!validator) return signal ? withValidationSignal(options, signal) : options;
1160
+ const runner = createAzureUrlValidatorRunner2(validator, {
1161
+ ...options.urlValidation ?? {},
1162
+ ...options.urlValidatorConcurrency !== void 0 ? { concurrency: options.urlValidatorConcurrency } : {},
1163
+ ...options.timeouts?.urlValidationMs !== void 0 ? { timeoutMs: options.timeouts.urlValidationMs } : options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
1164
+ ...signal ? { signal } : {},
1165
+ ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
1166
+ });
1167
+ return {
1168
+ ...withValidationSignal(options, signal),
1169
+ urlValidatorRunner: runner
1170
+ };
672
1171
  }
673
1172
  async function synthesizeSsmlSafe(client, ssml, options = {}) {
674
- const validationOptions = withValidationSignal(options.validation ?? options, options.signal);
1173
+ const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
675
1174
  const diagnostics = await Promise.resolve(validateAzureSsml2(ssml, validationOptions));
676
1175
  if (options.signal?.aborted) {
677
1176
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
@@ -690,7 +1189,11 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
690
1189
  ok: true,
691
1190
  success: true,
692
1191
  status: "success",
693
- value: await client.synthesizeSsml(ssml, { signal: options.signal })
1192
+ value: await client.synthesizeSsml(ssml, {
1193
+ signal: options.signal,
1194
+ timeoutMs: options.timeouts?.perChunkMs,
1195
+ timeouts: options.timeouts
1196
+ })
694
1197
  };
695
1198
  } catch (error) {
696
1199
  const synthesisError = toSynthesisError(error);
@@ -698,7 +1201,10 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
698
1201
  }
699
1202
  }
700
1203
  async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
701
- const validationOptions = withValidationSignal(options.validation ?? options, options.signal);
1204
+ const validationOptions = sharedValidationOptions(
1205
+ { ...options.validation ?? options, timeouts: options.timeouts },
1206
+ options.signal
1207
+ );
702
1208
  if (options.signal?.aborted) {
703
1209
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
704
1210
  return failure(error);
@@ -719,21 +1225,30 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
719
1225
  pending(index, "pending");
720
1226
  });
721
1227
  const validations = await Promise.all(
722
- chunks.map(async (chunk) => {
1228
+ chunks.map(async (chunk, index) => {
723
1229
  const ssml = typeof chunk === "string" ? chunk : chunk.ssml;
724
1230
  const sourceNodePath = typeof chunk === "string" ? options.sourceNodePath : chunk.sourceNodePath ?? options.sourceNodePath;
725
1231
  const diagnostics = await Promise.resolve(
726
- validateAzureSsml2(ssml, { ...validationOptions, ...sourceNodePath ? { sourceNodePath } : {} })
1232
+ validateAzureSsml2(ssml, {
1233
+ ...validationOptions,
1234
+ ...sourceNodePath ? { sourceNodePath } : {},
1235
+ chunkIndex: index
1236
+ })
727
1237
  );
728
1238
  return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
729
1239
  })
730
1240
  );
731
- const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
732
- if (firstInvalidIndex >= 0) {
733
- const error = new ChunkValidationError(firstInvalidIndex, validations[firstInvalidIndex] ?? []);
734
- pending(firstInvalidIndex, "failed", error);
1241
+ if (options.signal?.aborted) {
1242
+ const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
735
1243
  return failure(error);
736
1244
  }
1245
+ const chunkDiagnostics = validations.map((diagnostics, chunkIndex) => ({ chunkIndex, diagnostics })).filter((entry) => entry.diagnostics.length > 0);
1246
+ if (chunkDiagnostics.length > 0) {
1247
+ const error = new BatchChunkValidationError(chunkDiagnostics);
1248
+ for (const entry of chunkDiagnostics) pending(entry.chunkIndex, "failed", error);
1249
+ return failure(error);
1250
+ }
1251
+ let fallbackJobScope;
737
1252
  try {
738
1253
  if (client.synthesizeChunks) {
739
1254
  const normalizedChunks = chunks.map((chunk) => {
@@ -745,101 +1260,181 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
745
1260
  outputFormat: options.outputFormat,
746
1261
  signal: options.signal,
747
1262
  timeoutMs: options.timeoutMs,
748
- sourceNodePath: options.sourceNodePath
1263
+ timeouts: options.timeouts,
1264
+ sourceNodePath: options.sourceNodePath,
1265
+ concurrency: options.concurrency,
1266
+ retryOptions: options.retryOptions,
1267
+ cancelOnFailure: options.cancelOnFailure,
1268
+ resumeChunks: options.resumeChunks,
1269
+ resumeChunkIndices: options.resumeChunkIndices,
1270
+ customMerger: options.customMerger,
1271
+ outputMimeType: options.outputMimeType,
1272
+ postMergeValidator: options.postMergeValidator
749
1273
  });
750
1274
  return { ok: true, success: true, status: "success", value };
751
1275
  }
752
- const results = [];
753
- for (const [index, chunk] of chunks.entries()) {
754
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
755
- const sourceNodePath = input.sourceNodePath;
756
- const originalTextRange = input.originalTextRange;
757
- pending(index, "synthesizing");
758
- const startedAt = Date.now();
759
- try {
760
- const result = await client.synthesizeSsml(input.ssml, {
761
- outputFormat: options.outputFormat,
762
- signal: options.signal,
763
- timeoutMs: options.timeoutMs,
764
- sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
765
- });
766
- results.push({
767
- ...result,
768
- ...input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {},
769
- ...sourceNodePath ? {
770
- boundaries: result.boundaries?.map((event) => ({
771
- ...event,
772
- sourceNodePath: [...sourceNodePath],
773
- ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
774
- })),
775
- visemes: result.visemes?.map((event) => ({
776
- ...event,
777
- sourceNodePath: [...sourceNodePath],
778
- ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
779
- })),
780
- bookmarks: result.bookmarks?.map((event) => ({
781
- ...event,
782
- sourceNodePath: [...sourceNodePath],
783
- ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
784
- }))
785
- } : {},
786
- ...originalTextRange ? {
787
- boundaries: result.boundaries?.map((event) => ({
788
- ...event,
789
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
790
- })),
791
- wordBoundary: result.wordBoundary?.map((event) => ({
792
- ...event,
793
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
794
- })),
795
- wordBoundaries: result.wordBoundaries?.map((event) => ({
796
- ...event,
797
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
798
- })),
799
- visemes: result.visemes?.map((event) => ({
800
- ...event,
801
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
802
- })),
803
- bookmarks: result.bookmarks?.map((event) => ({
804
- ...event,
805
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
806
- }))
807
- } : {}
808
- });
809
- options.onProgress?.({
810
- currentChunk: index + 1,
811
- totalChunks: chunks.length,
812
- percent: chunks.length === 0 ? 100 : Math.round((index + 1) / chunks.length * 100),
813
- chunkIndex: index,
814
- originalTextRange: input.originalTextRange,
815
- status: "success",
816
- durationMs: Date.now() - startedAt
817
- });
818
- } catch (error) {
819
- options.onProgress?.({
820
- currentChunk: index,
821
- totalChunks: chunks.length,
822
- percent: chunks.length === 0 ? 100 : Math.round(index / chunks.length * 100),
823
- chunkIndex: index,
824
- originalTextRange: input.originalTextRange,
825
- status: "failed",
826
- durationMs: Date.now() - startedAt,
827
- error
828
- });
829
- throw error;
1276
+ const results = new Array(chunks.length);
1277
+ const cachedChunks = new Map((options.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
1278
+ for (const [index, cached] of cachedChunks) {
1279
+ if (index >= 0 && index < chunks.length) results[index] = cached;
1280
+ }
1281
+ 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));
1283
+ const jobScope = chunks.length > 1 || options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs) : void 0;
1284
+ fallbackJobScope = jobScope;
1285
+ const failedIndices = /* @__PURE__ */ new Set();
1286
+ let firstError;
1287
+ let completed = [...results].filter((result) => result !== void 0).length;
1288
+ let nextIndex = 0;
1289
+ const concurrency = resolveConcurrency2(options.concurrency, chunks.length);
1290
+ const worker = async () => {
1291
+ while (true) {
1292
+ const index = nextIndex++;
1293
+ if (index >= chunks.length) return;
1294
+ if (!shouldSynthesize(index)) continue;
1295
+ if (failedIndices.size > 0 && options.cancelOnFailure !== false) return;
1296
+ const chunk = chunks[index];
1297
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
1298
+ const sourceNodePath = input.sourceNodePath;
1299
+ const originalTextRange = input.originalTextRange;
1300
+ pending(index, "synthesizing");
1301
+ const startedAt = Date.now();
1302
+ try {
1303
+ const chunkTimeout = options.timeouts?.chunkWithRetriesMs ?? options.timeouts?.perChunkMs;
1304
+ const chunkScope = chunkTimeout !== void 0 || jobScope ? createSafeAbortScope(jobScope?.signal ?? options.signal, chunkTimeout ?? options.timeoutMs) : void 0;
1305
+ const chunkSignal = chunkScope?.signal ?? options.signal;
1306
+ let result;
1307
+ try {
1308
+ result = await retryableSynthesis(
1309
+ () => client.synthesizeSsml(input.ssml, {
1310
+ outputFormat: options.outputFormat,
1311
+ signal: chunkSignal,
1312
+ timeoutMs: options.timeouts?.perChunkMs ?? options.timeoutMs,
1313
+ sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
1314
+ }),
1315
+ options.retryOptions,
1316
+ chunkSignal,
1317
+ (retryAttempt, nextRetryDelayMs) => options.onProgress?.({
1318
+ currentChunk: completed,
1319
+ totalChunks: chunks.length,
1320
+ percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1321
+ chunkIndex: index,
1322
+ originalTextRange: input.originalTextRange,
1323
+ status: "synthesizing",
1324
+ durationMs: Date.now() - startedAt,
1325
+ retryAttempt,
1326
+ nextRetryDelayMs,
1327
+ isRetrying: true
1328
+ })
1329
+ );
1330
+ } catch (error) {
1331
+ if (chunkScope?.timedOut())
1332
+ throw new Error(`Speech synthesis timed out after ${chunkTimeout ?? options.timeoutMs} ms.`);
1333
+ throw error;
1334
+ } finally {
1335
+ chunkScope?.dispose();
1336
+ }
1337
+ results[index] = {
1338
+ ...result,
1339
+ ...input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {},
1340
+ ...sourceNodePath ? {
1341
+ boundaries: result.boundaries?.map((event) => ({
1342
+ ...event,
1343
+ sourceNodePath: [...sourceNodePath],
1344
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1345
+ })),
1346
+ visemes: result.visemes?.map((event) => ({
1347
+ ...event,
1348
+ sourceNodePath: [...sourceNodePath],
1349
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1350
+ })),
1351
+ bookmarks: result.bookmarks?.map((event) => ({
1352
+ ...event,
1353
+ sourceNodePath: [...sourceNodePath],
1354
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1355
+ }))
1356
+ } : {},
1357
+ ...originalTextRange ? {
1358
+ boundaries: result.boundaries?.map((event) => ({
1359
+ ...event,
1360
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1361
+ })),
1362
+ wordBoundary: result.wordBoundary?.map((event) => ({
1363
+ ...event,
1364
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1365
+ })),
1366
+ wordBoundaries: result.wordBoundaries?.map((event) => ({
1367
+ ...event,
1368
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1369
+ })),
1370
+ visemes: result.visemes?.map((event) => ({
1371
+ ...event,
1372
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1373
+ })),
1374
+ bookmarks: result.bookmarks?.map((event) => ({
1375
+ ...event,
1376
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1377
+ }))
1378
+ } : {}
1379
+ };
1380
+ completed += 1;
1381
+ options.onProgress?.({
1382
+ currentChunk: completed,
1383
+ totalChunks: chunks.length,
1384
+ percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1385
+ chunkIndex: index,
1386
+ originalTextRange: input.originalTextRange,
1387
+ status: "success",
1388
+ durationMs: Date.now() - startedAt
1389
+ });
1390
+ } catch (error) {
1391
+ failedIndices.add(index);
1392
+ options.onProgress?.({
1393
+ currentChunk: completed,
1394
+ totalChunks: chunks.length,
1395
+ percent: chunks.length === 0 ? 100 : Math.round(index / chunks.length * 100),
1396
+ chunkIndex: index,
1397
+ originalTextRange: input.originalTextRange,
1398
+ status: "failed",
1399
+ durationMs: Date.now() - startedAt,
1400
+ error
1401
+ });
1402
+ if (options.cancelOnFailure !== false) jobScope?.abort();
1403
+ firstError ?? (firstError = error);
1404
+ return;
1405
+ }
830
1406
  }
1407
+ };
1408
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
1409
+ if (failedIndices.size > 0) {
1410
+ const error = firstError ?? new Error("One or more SSML chunks failed to synthesize.");
1411
+ 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]),
1415
+ failedChunkIndices: [...failedIndices],
1416
+ totalChunks: chunks.length
1417
+ };
1418
+ throw error;
831
1419
  }
1420
+ const orderedResults = results.filter((result) => result !== void 0);
832
1421
  return {
833
1422
  ok: true,
834
1423
  success: true,
835
1424
  status: "success",
836
- value: mergeSynthesisResults(results, {
837
- format: options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3"
1425
+ value: await mergeSynthesisResults(orderedResults, {
1426
+ format: options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3",
1427
+ signal: jobScope?.signal ?? options.signal,
1428
+ customMerger: options.customMerger,
1429
+ outputMimeType: options.outputMimeType,
1430
+ postMergeValidator: options.postMergeValidator
838
1431
  })
839
1432
  };
840
1433
  } catch (error) {
841
1434
  const synthesisError = toSynthesisError(error);
842
- return failure(synthesisError);
1435
+ return failure(synthesisError, partialResultFrom(error));
1436
+ } finally {
1437
+ fallbackJobScope?.dispose();
843
1438
  }
844
1439
  }
845
1440
  function withValidationSignal(options, signal) {
@@ -860,14 +1455,14 @@ var AzureTtsClient = class {
860
1455
  __privateSet(this, _options, options);
861
1456
  }
862
1457
  async synthesize(ssml) {
863
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1458
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
864
1459
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
865
1460
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
866
- const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs };
1461
+ const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts };
867
1462
  return synthesizeSpeech(ssml, config);
868
1463
  }
869
1464
  async synthesizeSsml(ssml, options = {}) {
870
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1465
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
871
1466
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
872
1467
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
873
1468
  return synthesizeSsml(ssml, {
@@ -877,13 +1472,14 @@ var AzureTtsClient = class {
877
1472
  outputFormat: options.outputFormat ?? outputFormat,
878
1473
  signal: options.signal ?? signal,
879
1474
  timeoutMs: options.timeoutMs ?? timeoutMs,
1475
+ timeouts: options.timeouts ?? timeouts,
880
1476
  sourceNodePath: options.sourceNodePath,
881
1477
  sourceTextSegments: options.sourceTextSegments,
882
1478
  sourceMarkers: options.sourceMarkers
883
1479
  });
884
1480
  }
885
1481
  async synthesizeChunks(chunks, options = {}) {
886
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1482
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
887
1483
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
888
1484
  return synthesizeSsmlChunks(chunks, {
889
1485
  endpoint,
@@ -892,8 +1488,17 @@ var AzureTtsClient = class {
892
1488
  outputFormat: options.outputFormat ?? outputFormat,
893
1489
  signal: options.signal ?? signal,
894
1490
  timeoutMs: options.timeoutMs ?? timeoutMs,
1491
+ timeouts: options.timeouts ?? timeouts,
895
1492
  sourceNodePath: options.sourceNodePath,
896
- onProgress: options.onProgress ?? __privateGet(this, _options).onProgress
1493
+ onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1494
+ concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1495
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
1496
+ cancelOnFailure: options.cancelOnFailure,
1497
+ resumeChunks: options.resumeChunks,
1498
+ resumeChunkIndices: options.resumeChunkIndices,
1499
+ customMerger: options.customMerger,
1500
+ outputMimeType: options.outputMimeType,
1501
+ postMergeValidator: options.postMergeValidator
897
1502
  });
898
1503
  }
899
1504
  async synthesizeSsmlSafe(ssml, options = {}) {
@@ -905,7 +1510,10 @@ var AzureTtsClient = class {
905
1510
  outputFormat: options.outputFormat ?? __privateGet(this, _options).outputFormat,
906
1511
  signal: options.signal ?? __privateGet(this, _options).signal,
907
1512
  timeoutMs: options.timeoutMs ?? __privateGet(this, _options).timeoutMs,
908
- onProgress: options.onProgress ?? __privateGet(this, _options).onProgress
1513
+ timeouts: options.timeouts ?? __privateGet(this, _options).timeouts,
1514
+ onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1515
+ concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1516
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
909
1517
  });
910
1518
  }
911
1519
  async synthesizeSsmlChunksSafe(chunks, options = {}) {
@@ -996,14 +1604,18 @@ async function fetchAzureVoiceCatalog(options) {
996
1604
  voiceCount: sortedVoices.length,
997
1605
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
998
1606
  apiVersion: AZURE_VOICE_API_VERSION,
999
- regions
1607
+ regions,
1608
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1e3).toISOString(),
1609
+ regionDiffs: {}
1000
1610
  }
1001
1611
  };
1002
1612
  }
1003
1613
  export {
1614
+ AudioFormatMismatchError,
1004
1615
  AzureTtsClient,
1005
1616
  AzureTtsError,
1006
1617
  AzureTtsSdkError,
1618
+ BatchChunkValidationError,
1007
1619
  ChunkValidationError,
1008
1620
  DEFAULT_OUTPUT_FORMAT,
1009
1621
  MergeError,
@@ -1021,7 +1633,9 @@ export {
1021
1633
  fromPlainTextToSsml,
1022
1634
  getAzureVoiceCatalogMetadata,
1023
1635
  getBuiltInVoiceCatalogMetadata,
1636
+ getRetryAfterDelayMs,
1024
1637
  getSsmlSourceMap,
1638
+ inspectAudioSpecification,
1025
1639
  isValidAzureAudioDuration,
1026
1640
  mapSsmlTextNodes,
1027
1641
  mergeAudioBuffers,
@@ -1037,6 +1651,7 @@ export {
1037
1651
  synthesizeSsmlChunksSafe,
1038
1652
  synthesizeSsmlSafe,
1039
1653
  validateAzureSsml,
1654
+ validateAzureSsmlChunks,
1040
1655
  validateSsml,
1041
1656
  validateSsmlStructureIntegrity
1042
1657
  };