@ssml-builder-js/azure-tts-client 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
@@ -8,7 +8,7 @@ var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "
8
8
 
9
9
  // src/errors.ts
10
10
  var AzureTtsError = class extends Error {
11
- constructor(status, statusText, responseBody, requestId) {
11
+ constructor(status, statusText, responseBody, requestId, responseHeaders) {
12
12
  super(`Azure TTS request failed: ${status} ${statusText}`);
13
13
  this.kind = "azure-api-error";
14
14
  this.name = "AzureTtsError";
@@ -16,8 +16,37 @@ var AzureTtsError = class extends Error {
16
16
  this.statusText = statusText;
17
17
  this.responseBody = responseBody;
18
18
  this.requestId = requestId;
19
+ const value = responseHeaders instanceof Headers ? responseHeaders.get("retry-after") : responseHeaders?.["retry-after"] ?? responseHeaders?.["Retry-After"];
20
+ const seconds = value ? Number(value.trim()) : NaN;
21
+ const date = value ? Date.parse(value) : NaN;
22
+ if (Number.isFinite(seconds) && seconds >= 0) this.retryAfterMs = seconds * 1e3;
23
+ else if (Number.isFinite(date)) this.retryAfterMs = Math.max(0, date - Date.now());
19
24
  }
20
25
  };
26
+ function getRetryAfterDelayMs(error) {
27
+ if (error instanceof AzureTtsError && error.retryAfterMs !== void 0) return error.retryAfterMs;
28
+ if (!error || typeof error !== "object") return void 0;
29
+ const candidate = error;
30
+ if (typeof candidate.retryAfterMs === "number" && candidate.retryAfterMs >= 0) return candidate.retryAfterMs;
31
+ const headers = candidate.headers ?? candidate.response?.headers;
32
+ if (headers instanceof Headers) {
33
+ const value = headers.get("retry-after");
34
+ if (!value) return void 0;
35
+ const seconds = Number(value.trim());
36
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
37
+ const date = Date.parse(value);
38
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : void 0;
39
+ }
40
+ if (headers && typeof headers === "object") {
41
+ const value = headers["retry-after"] ?? headers["Retry-After"];
42
+ if (typeof value !== "string") return void 0;
43
+ const seconds = Number(value.trim());
44
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
45
+ const date = Date.parse(value);
46
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : void 0;
47
+ }
48
+ return void 0;
49
+ }
21
50
  var AzureTtsSdkError = class extends AzureTtsError {
22
51
  constructor(errorDetails) {
23
52
  super(0, "Speech SDK", errorDetails, null);
@@ -48,6 +77,14 @@ var MergeError = class extends Error {
48
77
  this.cause = cause;
49
78
  }
50
79
  };
80
+ var AudioFormatMismatchError = class extends Error {
81
+ constructor(message, inputSpecs = []) {
82
+ super(message);
83
+ this.kind = "audio-format-mismatch";
84
+ this.name = "AudioFormatMismatchError";
85
+ this.inputSpecs = inputSpecs;
86
+ }
87
+ };
51
88
  var UnsupportedMergeFormatError = class extends Error {
52
89
  constructor(format) {
53
90
  super(`Audio format "${format}" cannot be safely concatenated; container re-multiplexing is required.`);
@@ -57,7 +94,7 @@ var UnsupportedMergeFormatError = class extends Error {
57
94
  }
58
95
  };
59
96
  function toSynthesisError(error) {
60
- if (error instanceof AzureTtsError || error instanceof MergeError || error instanceof UnsupportedMergeFormatError || error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError)
97
+ if (error instanceof AzureTtsError || error instanceof MergeError || error instanceof AudioFormatMismatchError || error instanceof UnsupportedMergeFormatError || error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError)
61
98
  return error;
62
99
  const message = error instanceof Error ? error.message : String(error);
63
100
  if (/cancel|abort/i.test(message)) return new SynthesisCancelledError(message);
@@ -187,6 +224,115 @@ function parseWav(buffer) {
187
224
  }
188
225
  return { chunks, data, format };
189
226
  }
227
+ function formatNumber(format, pattern, fallback) {
228
+ const match = pattern.exec(format);
229
+ return match?.[1] ? Number(match[1]) : fallback;
230
+ }
231
+ function formatChannels(format, fallback) {
232
+ if (/stereo|2ch|dual/i.test(format)) return 2;
233
+ if (/mono|1ch/i.test(format)) return 1;
234
+ return fallback;
235
+ }
236
+ function formatAudioSpecification(format) {
237
+ const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
238
+ const channels = formatChannels(format, 0);
239
+ const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
240
+ const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1e3 : void 0;
241
+ const codec = /mp3|mpeg/i.test(format) ? "mp3" : /opus/i.test(format) ? "opus" : /silk/i.test(format) ? "silk" : /pcm|mulaw|alaw|siren/i.test(format) ? "pcm" : "unknown";
242
+ const bitDepthMatch = /(\d+)bit/i.exec(format);
243
+ const container = /(?:wav|wave|riff)/i.test(format) ? "riff-wave" : /mp3|mpeg/i.test(format) ? "mp3-raw" : /ogg/i.test(format) ? "ogg" : /webm/i.test(format) ? "webm" : /raw/i.test(format) ? "raw" : void 0;
244
+ return {
245
+ format,
246
+ mimeType: resolveMimeType(format),
247
+ codec,
248
+ sampleRate,
249
+ channels,
250
+ ...bitrate ? { bitrate } : {},
251
+ ...bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {},
252
+ ...container ? { container } : {},
253
+ isVbr: /vbr/i.test(format),
254
+ isCompressed: codec === "mp3" || codec === "opus" || codec === "silk"
255
+ };
256
+ }
257
+ function parseMp3Specification(buffer, format) {
258
+ const bytes = stripMp3Tags(buffer);
259
+ const bitrates = [
260
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
261
+ [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0],
262
+ [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0]
263
+ ];
264
+ const sampleRates = [
265
+ [44100, 48e3, 32e3],
266
+ [22050, 24e3, 16e3],
267
+ [11025, 12e3, 8e3]
268
+ ];
269
+ for (let index = 0; index + 4 <= bytes.length; index += 1) {
270
+ if (bytes[index] !== 255 || (bytes[index + 1] ?? 0) < 224) continue;
271
+ const header = bytes[index + 1] ?? 0;
272
+ const versionBits = header >> 3 & 3;
273
+ const layer = header >> 1 & 3;
274
+ const bitrateIndex = (bytes[index + 2] ?? 0) >> 4;
275
+ const sampleIndex = (bytes[index + 2] ?? 0) >> 2 & 3;
276
+ if (versionBits === 1 || layer !== 1 || bitrateIndex === 0 || bitrateIndex === 15 || sampleIndex === 3) continue;
277
+ const versionIndex = versionBits === 3 ? 0 : versionBits === 2 ? 1 : 2;
278
+ const bitrateTable = versionBits === 3 ? bitrates[1] : bitrates[2];
279
+ const sampleRate = sampleRates[versionIndex]?.[sampleIndex] ?? 0;
280
+ const bitrateKbps = bitrateTable?.[bitrateIndex] ?? 0;
281
+ if (!sampleRate || !bitrateKbps) continue;
282
+ return {
283
+ format,
284
+ mimeType: "audio/mpeg",
285
+ codec: "mp3",
286
+ sampleRate,
287
+ channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
288
+ bitrate: bitrateKbps * 1e3,
289
+ container: "mp3-raw",
290
+ isVbr: false,
291
+ isCompressed: true
292
+ };
293
+ }
294
+ return void 0;
295
+ }
296
+ function inspectAudioSpecification(buffer, format) {
297
+ if (isWavFormat(format) || ascii(new Uint8Array(buffer), 0, "RIFF")) {
298
+ const parsed = parseWav(buffer);
299
+ if (parsed.format.byteLength < 16) throw new Error("Invalid WAV fmt chunk.");
300
+ const view = new DataView(parsed.format.buffer, parsed.format.byteOffset, parsed.format.byteLength);
301
+ const sampleRate = view.getUint32(4, true);
302
+ const channels = view.getUint16(2, true);
303
+ const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
304
+ const formatCode = view.getUint16(0, true);
305
+ return {
306
+ format,
307
+ mimeType: "audio/wav",
308
+ codec: formatCode === 1 ? "pcm" : "unknown",
309
+ sampleRate,
310
+ channels,
311
+ ...sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {},
312
+ bitDepth: bitsPerSample,
313
+ container: "riff-wave",
314
+ isVbr: false,
315
+ isCompressed: formatCode !== 1
316
+ };
317
+ }
318
+ if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
319
+ return formatAudioSpecification(format);
320
+ }
321
+ function validateAudioSpecifications(specs) {
322
+ const first = specs[0];
323
+ if (!first) return;
324
+ const mismatch = specs.find(
325
+ (spec) => spec.sampleRate !== first.sampleRate || spec.channels !== first.channels || first.bitrate !== void 0 && spec.bitrate !== void 0 && spec.bitrate !== first.bitrate || first.bitDepth !== void 0 && spec.bitDepth !== void 0 && spec.bitDepth !== first.bitDepth || first.container !== void 0 && spec.container !== void 0 && spec.container !== first.container || first.isVbr !== void 0 && spec.isVbr !== void 0 && spec.isVbr !== first.isVbr
326
+ );
327
+ if (mismatch)
328
+ throw new AudioFormatMismatchError(
329
+ `Audio chunks have incompatible specifications: ${first.sampleRate}Hz/${first.channels}ch versus ${mismatch.sampleRate}Hz/${mismatch.channels}ch.`,
330
+ specs
331
+ );
332
+ }
333
+ function isAudioFormatMismatch(error) {
334
+ return error instanceof AudioFormatMismatchError || error !== null && typeof error === "object" && "kind" in error && error.kind === "audio-format-mismatch";
335
+ }
190
336
  function writeUint32(target, offset, value) {
191
337
  new DataView(target.buffer).setUint32(offset, value, true);
192
338
  }
@@ -272,6 +418,7 @@ function mergeAudioBuffers(buffers, options) {
272
418
  const format = typeof options === "string" ? options : options?.format;
273
419
  if (!format) throw new UnsupportedMergeFormatError("");
274
420
  try {
421
+ validateAudioSpecifications(buffers.map((buffer) => inspectAudioSpecification(buffer, format)));
275
422
  if (isWavFormat(format)) return mergeWavBuffers(buffers);
276
423
  if (isMp3Format(format)) {
277
424
  const parts = buffers.map(stripMp3Tags);
@@ -294,7 +441,8 @@ function mergeAudioBuffers(buffers, options) {
294
441
  }
295
442
  throw new UnsupportedMergeFormatError(format);
296
443
  } catch (error) {
297
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
444
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
445
+ throw error;
298
446
  throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
299
447
  }
300
448
  }
@@ -372,17 +520,24 @@ async function synthesizeSsml(ssml, config) {
372
520
  return {
373
521
  originalTextRange: { ...marker.originalTextRange },
374
522
  sourceNodePath: [...marker.sourceNodePath],
375
- textRange: { ...marker.originalTextRange }
523
+ textRange: { ...marker.originalTextRange },
524
+ mappingStatus: "exact"
376
525
  };
377
526
  }
378
- if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) return {};
527
+ if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) {
528
+ return { mappingStatus: "unmapped" };
529
+ }
379
530
  const value = text ?? "";
380
531
  let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? offsetHint : -1;
381
- if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value)
532
+ let mappingStatus = "exact";
533
+ if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value) {
382
534
  localStart = -1;
535
+ mappingStatus = "fallback";
536
+ }
383
537
  if (localStart < 0 || localStart > sourceText.length) {
384
538
  localStart = value ? sourceText.indexOf(value, sourceEventCursor) : sourceEventCursor;
385
539
  if (localStart < 0) localStart = value ? sourceText.indexOf(value) : sourceEventCursor;
540
+ mappingStatus = "fallback";
386
541
  }
387
542
  localStart = Math.max(0, localStart);
388
543
  const localEnd = Math.min(sourceText.length, localStart + value.length);
@@ -393,7 +548,8 @@ async function synthesizeSsml(ssml, config) {
393
548
  return {
394
549
  originalTextRange: { ...fallbackRange },
395
550
  textRange: { ...fallbackRange },
396
- ...segment ? { sourceNodePath: [...segment.sourceNodePath] } : config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {}
551
+ ...segment ? { sourceNodePath: [...segment.sourceNodePath] } : config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
552
+ mappingStatus: segment || config.sourceTextRange || config.sourceNodePath ? mappingStatus : "unmapped"
397
553
  };
398
554
  };
399
555
  synthesizer.wordBoundary = (_sender, event) => {
@@ -442,20 +598,32 @@ async function synthesizeSsml(ssml, config) {
442
598
  );
443
599
  const durationMs = result.audioDuration ? ticksToMilliseconds(result.audioDuration) : eventDurationMs;
444
600
  const requestId = result.resultId;
445
- const addSourceMetadata = (event) => ({
446
- ...event,
447
- ...config.sourceTextRange && !("textRange" in event) ? { textRange: { ...config.sourceTextRange } } : {},
448
- ...config.sourceTextRange && !("originalTextRange" in event) ? { originalTextRange: { ...config.sourceTextRange } } : {},
449
- ...config.chunkIndex !== void 0 ? { chunkIndex: config.chunkIndex } : {},
450
- ...config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
451
- ...requestId ? { requestId } : {}
452
- });
601
+ const addSourceMetadata = (event) => {
602
+ const mapped = {
603
+ ...event,
604
+ ...config.sourceTextRange && !("textRange" in event) ? { textRange: { ...config.sourceTextRange } } : {},
605
+ ...config.sourceTextRange && !("originalTextRange" in event) ? { originalTextRange: { ...config.sourceTextRange } } : {},
606
+ ...config.chunkIndex !== void 0 ? { chunkIndex: config.chunkIndex } : {},
607
+ ...config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
608
+ ...requestId ? { requestId } : {}
609
+ };
610
+ if (event.mappingStatus === "unmapped") {
611
+ Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
612
+ Object.defineProperty(mapped, "toJSON", {
613
+ value: () => ({ ...mapped, mappingStatus: "unmapped" }),
614
+ enumerable: false
615
+ });
616
+ }
617
+ return mapped;
618
+ };
453
619
  const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
454
620
  const sourceVisemes = visemes.map((viseme) => addSourceMetadata(viseme));
455
621
  const sourceBookmarks = bookmarks.map((bookmark) => addSourceMetadata(bookmark));
456
622
  resolve({
457
623
  audioData: result.audioData,
458
624
  durationMs,
625
+ audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
626
+ mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
459
627
  ...config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {},
460
628
  ...requestId ? { requestId } : {},
461
629
  ...sourceBoundaries.length > 0 ? { boundaries: sourceBoundaries, wordBoundary: sourceBoundaries, wordBoundaries: sourceBoundaries } : {},
@@ -468,10 +636,11 @@ async function synthesizeSsml(ssml, config) {
468
636
  abortHandler = () => rejectWithError(new SynthesisCancelledError());
469
637
  config.signal.addEventListener("abort", abortHandler, { once: true });
470
638
  }
471
- if (config.timeoutMs !== void 0 && config.timeoutMs > 0) {
639
+ const timeoutMs = config.timeouts?.perChunkMs ?? config.timeoutMs;
640
+ if (timeoutMs !== void 0 && timeoutMs > 0) {
472
641
  timeout = setTimeout(
473
- () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeoutMs} ms.`)),
474
- config.timeoutMs
642
+ () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
643
+ timeoutMs
475
644
  );
476
645
  }
477
646
  synthesizer.speakSsmlAsync(ssml, cb, rejectWithError);
@@ -480,9 +649,109 @@ async function synthesizeSsml(ssml, config) {
480
649
  }
481
650
  });
482
651
  }
652
+ function isRetryableSynthesisError(error) {
653
+ if (error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError) return false;
654
+ if (error instanceof AzureTtsError && error.status !== 0)
655
+ return error.status === 429 || error.status >= 500 && error.status < 600;
656
+ const message = error instanceof Error ? error.message : String(error);
657
+ if (/\b4\d{2}\b/.test(message)) return false;
658
+ const status = error && typeof error === "object" && "status" in error ? error.status : void 0;
659
+ if (typeof status === "number") return status === 429 || status >= 500 && status < 600;
660
+ return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
661
+ }
662
+ function retryDelay(options, retryAttempt, error) {
663
+ const retryAfterMs = getRetryAfterDelayMs(error);
664
+ if (retryAfterMs !== void 0) return retryAfterMs;
665
+ const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
666
+ return Math.floor(Math.random() * (base + 1));
667
+ }
668
+ function resolveConcurrency(value, total) {
669
+ if (value === void 0) return 1;
670
+ if (value === Infinity) return Math.max(1, total);
671
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
672
+ }
673
+ async function waitForRetry(delayMs, signal) {
674
+ if (signal?.aborted) throw new SynthesisCancelledError();
675
+ if (delayMs <= 0) return;
676
+ await new Promise((resolve, reject) => {
677
+ let timer;
678
+ const abort = () => {
679
+ clearTimeout(timer);
680
+ signal?.removeEventListener("abort", abort);
681
+ reject(new SynthesisCancelledError());
682
+ };
683
+ timer = setTimeout(() => {
684
+ signal?.removeEventListener("abort", abort);
685
+ resolve();
686
+ }, delayMs);
687
+ if (signal) {
688
+ signal.addEventListener("abort", abort, { once: true });
689
+ }
690
+ });
691
+ }
692
+ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
693
+ const options = retryOptions ? {
694
+ maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
695
+ initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
696
+ maxDelayMs: Math.max(0, retryOptions.maxDelayMs),
697
+ shouldRetry: retryOptions.shouldRetry
698
+ } : void 0;
699
+ let attempt = 0;
700
+ while (true) {
701
+ if (config.signal?.aborted) throw new SynthesisCancelledError();
702
+ try {
703
+ return await synthesizeSsml(ssml, config);
704
+ } catch (error) {
705
+ if (!options || attempt >= options.maxRetries || !(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error)))
706
+ throw error;
707
+ attempt += 1;
708
+ const delayMs = retryDelay(options, attempt, error);
709
+ onRetry(attempt, delayMs);
710
+ await waitForRetry(delayMs, config.signal);
711
+ }
712
+ }
713
+ }
714
+ function createAbortScope(parent, timeoutMs) {
715
+ const controller = new AbortController();
716
+ let didTimeout = false;
717
+ const onAbort = () => controller.abort();
718
+ if (parent?.aborted) controller.abort();
719
+ parent?.addEventListener("abort", onAbort, { once: true });
720
+ const timer = timeoutMs !== void 0 && timeoutMs > 0 ? setTimeout(() => {
721
+ didTimeout = true;
722
+ controller.abort();
723
+ }, timeoutMs) : void 0;
724
+ return {
725
+ signal: controller.signal,
726
+ timedOut: () => didTimeout,
727
+ dispose: () => {
728
+ if (timer) clearTimeout(timer);
729
+ parent?.removeEventListener("abort", onAbort);
730
+ },
731
+ abort: () => controller.abort()
732
+ };
733
+ }
734
+ async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs, onRetry) {
735
+ const scope = createAbortScope(config.signal, timeoutMs);
736
+ try {
737
+ return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry);
738
+ } catch (error) {
739
+ if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
740
+ throw error;
741
+ } finally {
742
+ scope.dispose();
743
+ }
744
+ }
483
745
  async function synthesizeSsmlChunks(chunks, config) {
484
- const results = [];
746
+ const results = new Array(chunks.length);
485
747
  const totalChunks = chunks.length;
748
+ const cachedChunks = new Map((config.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
749
+ for (const [index, cached] of cachedChunks) {
750
+ if (index >= 0 && index < totalChunks) results[index] = cached;
751
+ }
752
+ const requestedIndices = config.resumeChunkIndices ? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks)) : void 0;
753
+ const shouldSynthesize = (index) => !cachedChunks.has(index) && (requestedIndices === void 0 || requestedIndices.has(index));
754
+ const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
486
755
  const report = (event) => config.onProgress?.(event);
487
756
  for (const [index, chunk] of chunks.entries()) {
488
757
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
@@ -496,57 +765,112 @@ async function synthesizeSsmlChunks(chunks, config) {
496
765
  durationMs: 0
497
766
  });
498
767
  }
499
- for (const [index, chunk] of chunks.entries()) {
500
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
501
- report({
502
- currentChunk: index,
503
- totalChunks,
504
- percent: totalChunks === 0 ? 100 : Math.round(index / totalChunks * 100),
505
- chunkIndex: index,
506
- originalTextRange: input.originalTextRange,
507
- status: "synthesizing",
508
- durationMs: 0
509
- });
510
- const startedAt = Date.now();
511
- try {
512
- const result = await synthesizeSsml(input.ssml, {
513
- ...config,
514
- ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
515
- ...input.sourceNodePath ?? config.sourceNodePath ? { sourceNodePath: [...input.sourceNodePath ?? config.sourceNodePath ?? []] } : {},
516
- ...input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {},
517
- ...input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {},
518
- chunkIndex: index,
519
- onProgress: void 0
520
- });
521
- results.push(result);
522
- report({
523
- currentChunk: index + 1,
524
- totalChunks,
525
- percent: totalChunks === 0 ? 100 : Math.round((index + 1) / totalChunks * 100),
526
- chunkIndex: index,
527
- originalTextRange: input.originalTextRange,
528
- status: "success",
529
- durationMs: Date.now() - startedAt
530
- });
531
- } catch (error) {
768
+ let completed = [...results].filter((result) => result !== void 0).length;
769
+ let nextIndex = 0;
770
+ const concurrency = resolveConcurrency(config.concurrency, chunks.length);
771
+ let firstError;
772
+ const failedIndices = /* @__PURE__ */ new Set();
773
+ const worker = async () => {
774
+ while (true) {
775
+ const index = nextIndex++;
776
+ if (index >= chunks.length) return;
777
+ if (!shouldSynthesize(index)) continue;
778
+ if (firstError && config.cancelOnFailure !== false) return;
779
+ const chunk = chunks[index];
780
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
532
781
  report({
533
- currentChunk: index,
782
+ currentChunk: completed,
534
783
  totalChunks,
535
- percent: totalChunks === 0 ? 100 : Math.round(index / totalChunks * 100),
784
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
536
785
  chunkIndex: index,
537
786
  originalTextRange: input.originalTextRange,
538
- status: "failed",
539
- durationMs: Date.now() - startedAt,
540
- error
787
+ status: "synthesizing",
788
+ durationMs: 0
541
789
  });
542
- throw error;
790
+ const startedAt = Date.now();
791
+ try {
792
+ const result = await synthesizeChunkWithTimeout(
793
+ input.ssml,
794
+ {
795
+ ...config,
796
+ signal: scope.signal,
797
+ ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
798
+ ...input.sourceNodePath ?? config.sourceNodePath ? { sourceNodePath: [...input.sourceNodePath ?? config.sourceNodePath ?? []] } : {},
799
+ ...input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {},
800
+ ...input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {},
801
+ chunkIndex: index,
802
+ onProgress: void 0
803
+ },
804
+ config.retryOptions,
805
+ config.timeouts?.chunkWithRetriesMs ?? config.timeouts?.perChunkMs ?? config.timeoutMs,
806
+ (retryAttempt, nextRetryDelayMs) => report({
807
+ currentChunk: completed,
808
+ totalChunks,
809
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
810
+ chunkIndex: index,
811
+ originalTextRange: input.originalTextRange,
812
+ status: "synthesizing",
813
+ durationMs: Date.now() - startedAt,
814
+ retryAttempt,
815
+ nextRetryDelayMs,
816
+ isRetrying: true
817
+ })
818
+ );
819
+ results[index] = result;
820
+ completed += 1;
821
+ report({
822
+ currentChunk: completed,
823
+ totalChunks,
824
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
825
+ chunkIndex: index,
826
+ originalTextRange: input.originalTextRange,
827
+ status: "success",
828
+ durationMs: Date.now() - startedAt
829
+ });
830
+ } catch (error) {
831
+ failedIndices.add(index);
832
+ report({
833
+ currentChunk: completed,
834
+ totalChunks,
835
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
836
+ chunkIndex: index,
837
+ originalTextRange: input.originalTextRange,
838
+ status: "failed",
839
+ durationMs: Date.now() - startedAt,
840
+ error
841
+ });
842
+ firstError ?? (firstError = scope.timedOut() ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`) : error);
843
+ if (config.cancelOnFailure !== false) scope.abort();
844
+ return;
845
+ }
543
846
  }
847
+ };
848
+ try {
849
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
850
+ if (firstError) throw firstError;
851
+ const orderedResults = results.filter((result) => result !== void 0);
852
+ return await mergeSynthesisResults(orderedResults, {
853
+ format: config.outputFormat ?? DEFAULT_OUTPUT_FORMAT,
854
+ signal: scope.signal,
855
+ customMerger: config.customMerger,
856
+ outputMimeType: config.outputMimeType,
857
+ postMergeValidator: config.postMergeValidator
858
+ });
859
+ } catch (error) {
860
+ const partial = {
861
+ synthesizedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
862
+ completedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
863
+ pendingChunkIndices: chunks.flatMap((_chunk, chunkIndex) => results[chunkIndex] ? [] : [chunkIndex]),
864
+ failedChunkIndices: [...failedIndices],
865
+ totalChunks
866
+ };
867
+ if (error && typeof error === "object") error.partialResult = partial;
868
+ throw error;
869
+ } finally {
870
+ scope.dispose();
544
871
  }
545
- return mergeSynthesisResults(results, {
546
- format: config.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3"
547
- });
548
872
  }
549
- function createMergedResult(results, audioData, format) {
873
+ function createMergedResult(results, audioData, format, audioSpec, outputMimeType) {
550
874
  const boundaries = [];
551
875
  const visemes = [];
552
876
  const bookmarks = [];
@@ -565,7 +889,8 @@ function createMergedResult(results, audioData, format) {
565
889
  ...boundary.sourceNodePath ? { sourceNodePath: [...boundary.sourceNodePath] } : {},
566
890
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
567
891
  ...textRange ? { textRange: { ...textRange } } : {},
568
- ...requestId ? { requestId } : {}
892
+ ...requestId ? { requestId } : {},
893
+ mappingStatus: boundary.mappingStatus ?? "unmapped"
569
894
  });
570
895
  }
571
896
  for (const viseme of result.visemes ?? []) {
@@ -580,7 +905,8 @@ function createMergedResult(results, audioData, format) {
580
905
  ...viseme.sourceNodePath ? { sourceNodePath: [...viseme.sourceNodePath] } : {},
581
906
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
582
907
  ...textRange ? { textRange: { ...textRange } } : {},
583
- ...requestId ? { requestId } : {}
908
+ ...requestId ? { requestId } : {},
909
+ mappingStatus: viseme.mappingStatus ?? "unmapped"
584
910
  });
585
911
  }
586
912
  for (const bookmark of result.bookmarks ?? []) {
@@ -595,7 +921,8 @@ function createMergedResult(results, audioData, format) {
595
921
  ...bookmark.sourceNodePath ? { sourceNodePath: [...bookmark.sourceNodePath] } : {},
596
922
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
597
923
  ...textRange ? { textRange: { ...textRange } } : {},
598
- ...requestId ? { requestId } : {}
924
+ ...requestId ? { requestId } : {},
925
+ mappingStatus: bookmark.mappingStatus ?? "unmapped"
599
926
  });
600
927
  }
601
928
  durationOffset += Math.max(0, result.durationMs);
@@ -604,6 +931,8 @@ function createMergedResult(results, audioData, format) {
604
931
  audioData,
605
932
  durationMs: durationOffset,
606
933
  mimeType: resolveMimeType(format),
934
+ audioSpec: audioSpec ?? formatAudioSpecification(format),
935
+ ...outputMimeType ? { mimeType: outputMimeType } : {},
607
936
  ...boundaries.length > 0 ? { boundaries, wordBoundary: boundaries, wordBoundaries: boundaries } : {},
608
937
  ...visemes.length > 0 ? { visemes } : {},
609
938
  ...bookmarks.length > 0 ? { bookmarks } : {},
@@ -616,19 +945,73 @@ function mergeSynthesisResults(results, options) {
616
945
  const format = resolvedOptions?.format;
617
946
  if (!format) throw new UnsupportedMergeFormatError("");
618
947
  const buffers = results.map((result) => result.audioData);
948
+ const inputSpecs = results.map((result) => result.audioSpec ?? inspectAudioSpecification(result.audioData, format));
949
+ validateAudioSpecifications(inputSpecs);
950
+ const signal = resolvedOptions.signal ?? new AbortController().signal;
951
+ if (signal.aborted) throw new SynthesisCancelledError();
619
952
  if (resolvedOptions.customMerger) {
620
- return Promise.resolve().then(() => resolvedOptions.customMerger?.(buffers, format)).then((merged) => {
953
+ return Promise.resolve().then(
954
+ () => resolvedOptions.customMerger?.(buffers, {
955
+ format,
956
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
957
+ inputSpecs,
958
+ signal
959
+ })
960
+ ).then((merged) => {
621
961
  if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
622
- return createMergedResult(results, merged, format);
962
+ if (!(merged instanceof ArrayBuffer) || buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
963
+ throw new MergeError("The custom audio merger returned an invalid audio buffer.");
964
+ if (signal.aborted) throw new SynthesisCancelledError();
965
+ const result = createMergedResult(
966
+ results,
967
+ merged,
968
+ format,
969
+ inspectAudioSpecification(merged, format),
970
+ resolvedOptions.outputMimeType
971
+ );
972
+ return Promise.resolve(
973
+ resolvedOptions.postMergeValidator?.(result, {
974
+ format,
975
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
976
+ inputSpecs,
977
+ signal
978
+ })
979
+ ).then((valid) => {
980
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
981
+ return result;
982
+ });
623
983
  }).catch((error) => {
624
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
984
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
985
+ throw error;
625
986
  throw new MergeError(`Custom audio merger failed for format "${format}".`, error);
626
987
  });
627
988
  }
628
989
  try {
629
- return createMergedResult(results, mergeAudioBuffers(buffers, { format }), format);
990
+ const result = createMergedResult(
991
+ results,
992
+ mergeAudioBuffers(buffers, { format }),
993
+ format,
994
+ inputSpecs[0],
995
+ resolvedOptions.outputMimeType
996
+ );
997
+ if (resolvedOptions.postMergeValidator) {
998
+ const validation = resolvedOptions.postMergeValidator(result, {
999
+ format,
1000
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
1001
+ inputSpecs,
1002
+ signal
1003
+ });
1004
+ if (validation instanceof Promise)
1005
+ return validation.then((valid) => {
1006
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1007
+ return result;
1008
+ });
1009
+ if (validation === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1010
+ }
1011
+ return result;
630
1012
  } catch (error) {
631
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
1013
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
1014
+ throw error;
632
1015
  throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
633
1016
  }
634
1017
  }
@@ -637,7 +1020,10 @@ async function synthesizeSpeech(ssml, config) {
637
1020
  }
638
1021
 
639
1022
  // src/safe.ts
640
- import { validateAzureSsml } from "@ssml-builder-js/ssml-core";
1023
+ import {
1024
+ createAzureUrlValidatorRunner,
1025
+ validateAzureSsml
1026
+ } from "@ssml-builder-js/ssml-core";
641
1027
  var ChunkValidationError = class extends Error {
642
1028
  constructor(chunkIndex, diagnostics) {
643
1029
  super(`SSML validation failed for chunk ${chunkIndex}; the Azure Speech API was not called.`);
@@ -647,11 +1033,125 @@ var ChunkValidationError = class extends Error {
647
1033
  this.diagnostics = diagnostics;
648
1034
  }
649
1035
  };
650
- function failure(error) {
651
- return { ok: false, success: false, status: error.kind, error };
1036
+ var BatchChunkValidationError = class extends ChunkValidationError {
1037
+ constructor(chunkDiagnostics) {
1038
+ const first = chunkDiagnostics[0];
1039
+ super(first?.chunkIndex ?? -1, first?.diagnostics ?? []);
1040
+ this.name = "BatchChunkValidationError";
1041
+ this.message = `SSML validation failed for ${chunkDiagnostics.length} chunk(s); the Azure Speech API was not called.`;
1042
+ this.chunkDiagnostics = chunkDiagnostics;
1043
+ this.totalErrorCount = chunkDiagnostics.reduce((total, chunk) => total + chunk.diagnostics.length, 0);
1044
+ this.errorCount = this.totalErrorCount;
1045
+ this.totalErrors = this.totalErrorCount;
1046
+ }
1047
+ };
1048
+ function failure(error, partialResult) {
1049
+ return {
1050
+ ok: false,
1051
+ success: false,
1052
+ status: error.kind,
1053
+ error,
1054
+ ...partialResult ? { partialResult } : {}
1055
+ };
1056
+ }
1057
+ function partialResultFrom(error) {
1058
+ if (!error || typeof error !== "object") return void 0;
1059
+ const partial = error.partialResult;
1060
+ if (!partial || typeof partial !== "object") return void 0;
1061
+ return partial;
1062
+ }
1063
+ function createSafeAbortScope(parent, timeoutMs) {
1064
+ const controller = new AbortController();
1065
+ let didTimeout = false;
1066
+ const onAbort = () => controller.abort();
1067
+ if (parent?.aborted) controller.abort();
1068
+ parent?.addEventListener("abort", onAbort, { once: true });
1069
+ const timer = timeoutMs !== void 0 && timeoutMs > 0 ? setTimeout(() => {
1070
+ didTimeout = true;
1071
+ controller.abort();
1072
+ }, timeoutMs) : void 0;
1073
+ return {
1074
+ signal: controller.signal,
1075
+ timedOut: () => didTimeout,
1076
+ dispose: () => {
1077
+ if (timer) clearTimeout(timer);
1078
+ parent?.removeEventListener("abort", onAbort);
1079
+ },
1080
+ abort: () => controller.abort()
1081
+ };
1082
+ }
1083
+ function isRetryable(error) {
1084
+ if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
1085
+ const status = error && typeof error === "object" && "status" in error ? error.status : void 0;
1086
+ if (typeof status === "number" && status !== 0) return status === 429 || status >= 500 && status < 600;
1087
+ const message = error instanceof Error ? error.message : String(error);
1088
+ if (/\b4\d{2}\b/.test(message)) return false;
1089
+ return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
1090
+ }
1091
+ function delayForRetry(options, attempt) {
1092
+ const maxDelay = Math.max(0, options.maxDelayMs);
1093
+ const base = Math.min(maxDelay, Math.max(0, options.initialDelayMs) * 2 ** Math.max(0, attempt - 1));
1094
+ return Math.floor(Math.random() * (base + 1));
1095
+ }
1096
+ function retryDelayForError(options, attempt, error) {
1097
+ return getRetryAfterDelayMs(error) ?? delayForRetry(options, attempt);
1098
+ }
1099
+ function resolveConcurrency2(value, total) {
1100
+ if (value === void 0) return 1;
1101
+ if (value === Infinity) return Math.max(1, total);
1102
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
1103
+ }
1104
+ async function retryableSynthesis(synthesize, options, signal, onRetry) {
1105
+ const retry = options ? {
1106
+ maxRetries: Math.max(0, Math.floor(options.maxRetries)),
1107
+ initialDelayMs: options.initialDelayMs,
1108
+ maxDelayMs: options.maxDelayMs,
1109
+ shouldRetry: options.shouldRetry
1110
+ } : void 0;
1111
+ let attempt = 0;
1112
+ while (true) {
1113
+ if (signal?.aborted) throw new Error("Speech synthesis was cancelled.");
1114
+ try {
1115
+ return await synthesize();
1116
+ } catch (error) {
1117
+ if (!retry || attempt >= retry.maxRetries || !(retry.shouldRetry?.(error, attempt + 1) ?? isRetryable(error)))
1118
+ throw error;
1119
+ attempt += 1;
1120
+ const delayMs = retryDelayForError(retry, attempt, error);
1121
+ onRetry(attempt, delayMs);
1122
+ if (delayMs > 0)
1123
+ await new Promise((resolve, reject) => {
1124
+ const timer = setTimeout(() => {
1125
+ signal?.removeEventListener("abort", abort);
1126
+ resolve();
1127
+ }, delayMs);
1128
+ const abort = () => {
1129
+ clearTimeout(timer);
1130
+ signal?.removeEventListener("abort", abort);
1131
+ reject(new Error("Speech synthesis was cancelled."));
1132
+ };
1133
+ signal?.addEventListener("abort", abort, { once: true });
1134
+ });
1135
+ }
1136
+ }
1137
+ }
1138
+ function sharedValidationOptions(options, signal) {
1139
+ const validator = options.urlValidator ?? options.customUrlValidator;
1140
+ if (!validator) return signal ? withValidationSignal(options, signal) : options;
1141
+ const runner = createAzureUrlValidatorRunner(validator, {
1142
+ ...options.urlValidation ?? {},
1143
+ ...options.urlValidatorConcurrency !== void 0 ? { concurrency: options.urlValidatorConcurrency } : {},
1144
+ ...options.timeouts?.urlValidationMs !== void 0 ? { timeoutMs: options.timeouts.urlValidationMs } : options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
1145
+ ...signal ? { signal } : {},
1146
+ ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
1147
+ });
1148
+ return {
1149
+ ...withValidationSignal(options, signal),
1150
+ urlValidatorRunner: runner
1151
+ };
652
1152
  }
653
1153
  async function synthesizeSsmlSafe(client, ssml, options = {}) {
654
- const validationOptions = withValidationSignal(options.validation ?? options, options.signal);
1154
+ const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
655
1155
  const diagnostics = await Promise.resolve(validateAzureSsml(ssml, validationOptions));
656
1156
  if (options.signal?.aborted) {
657
1157
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
@@ -670,7 +1170,11 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
670
1170
  ok: true,
671
1171
  success: true,
672
1172
  status: "success",
673
- value: await client.synthesizeSsml(ssml, { signal: options.signal })
1173
+ value: await client.synthesizeSsml(ssml, {
1174
+ signal: options.signal,
1175
+ timeoutMs: options.timeouts?.perChunkMs,
1176
+ timeouts: options.timeouts
1177
+ })
674
1178
  };
675
1179
  } catch (error) {
676
1180
  const synthesisError = toSynthesisError(error);
@@ -678,7 +1182,10 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
678
1182
  }
679
1183
  }
680
1184
  async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
681
- const validationOptions = withValidationSignal(options.validation ?? options, options.signal);
1185
+ const validationOptions = sharedValidationOptions(
1186
+ { ...options.validation ?? options, timeouts: options.timeouts },
1187
+ options.signal
1188
+ );
682
1189
  if (options.signal?.aborted) {
683
1190
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
684
1191
  return failure(error);
@@ -699,21 +1206,30 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
699
1206
  pending(index, "pending");
700
1207
  });
701
1208
  const validations = await Promise.all(
702
- chunks.map(async (chunk) => {
1209
+ chunks.map(async (chunk, index) => {
703
1210
  const ssml = typeof chunk === "string" ? chunk : chunk.ssml;
704
1211
  const sourceNodePath = typeof chunk === "string" ? options.sourceNodePath : chunk.sourceNodePath ?? options.sourceNodePath;
705
1212
  const diagnostics = await Promise.resolve(
706
- validateAzureSsml(ssml, { ...validationOptions, ...sourceNodePath ? { sourceNodePath } : {} })
1213
+ validateAzureSsml(ssml, {
1214
+ ...validationOptions,
1215
+ ...sourceNodePath ? { sourceNodePath } : {},
1216
+ chunkIndex: index
1217
+ })
707
1218
  );
708
1219
  return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
709
1220
  })
710
1221
  );
711
- const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
712
- if (firstInvalidIndex >= 0) {
713
- const error = new ChunkValidationError(firstInvalidIndex, validations[firstInvalidIndex] ?? []);
714
- pending(firstInvalidIndex, "failed", error);
1222
+ if (options.signal?.aborted) {
1223
+ const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
715
1224
  return failure(error);
716
1225
  }
1226
+ const chunkDiagnostics = validations.map((diagnostics, chunkIndex) => ({ chunkIndex, diagnostics })).filter((entry) => entry.diagnostics.length > 0);
1227
+ if (chunkDiagnostics.length > 0) {
1228
+ const error = new BatchChunkValidationError(chunkDiagnostics);
1229
+ for (const entry of chunkDiagnostics) pending(entry.chunkIndex, "failed", error);
1230
+ return failure(error);
1231
+ }
1232
+ let fallbackJobScope;
717
1233
  try {
718
1234
  if (client.synthesizeChunks) {
719
1235
  const normalizedChunks = chunks.map((chunk) => {
@@ -725,101 +1241,181 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
725
1241
  outputFormat: options.outputFormat,
726
1242
  signal: options.signal,
727
1243
  timeoutMs: options.timeoutMs,
728
- sourceNodePath: options.sourceNodePath
1244
+ timeouts: options.timeouts,
1245
+ sourceNodePath: options.sourceNodePath,
1246
+ concurrency: options.concurrency,
1247
+ retryOptions: options.retryOptions,
1248
+ cancelOnFailure: options.cancelOnFailure,
1249
+ resumeChunks: options.resumeChunks,
1250
+ resumeChunkIndices: options.resumeChunkIndices,
1251
+ customMerger: options.customMerger,
1252
+ outputMimeType: options.outputMimeType,
1253
+ postMergeValidator: options.postMergeValidator
729
1254
  });
730
1255
  return { ok: true, success: true, status: "success", value };
731
1256
  }
732
- const results = [];
733
- for (const [index, chunk] of chunks.entries()) {
734
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
735
- const sourceNodePath = input.sourceNodePath;
736
- const originalTextRange = input.originalTextRange;
737
- pending(index, "synthesizing");
738
- const startedAt = Date.now();
739
- try {
740
- const result = await client.synthesizeSsml(input.ssml, {
741
- outputFormat: options.outputFormat,
742
- signal: options.signal,
743
- timeoutMs: options.timeoutMs,
744
- sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
745
- });
746
- results.push({
747
- ...result,
748
- ...input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {},
749
- ...sourceNodePath ? {
750
- boundaries: result.boundaries?.map((event) => ({
751
- ...event,
752
- sourceNodePath: [...sourceNodePath],
753
- ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
754
- })),
755
- visemes: result.visemes?.map((event) => ({
756
- ...event,
757
- sourceNodePath: [...sourceNodePath],
758
- ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
759
- })),
760
- bookmarks: result.bookmarks?.map((event) => ({
761
- ...event,
762
- sourceNodePath: [...sourceNodePath],
763
- ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
764
- }))
765
- } : {},
766
- ...originalTextRange ? {
767
- boundaries: result.boundaries?.map((event) => ({
768
- ...event,
769
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
770
- })),
771
- wordBoundary: result.wordBoundary?.map((event) => ({
772
- ...event,
773
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
774
- })),
775
- wordBoundaries: result.wordBoundaries?.map((event) => ({
776
- ...event,
777
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
778
- })),
779
- visemes: result.visemes?.map((event) => ({
780
- ...event,
781
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
782
- })),
783
- bookmarks: result.bookmarks?.map((event) => ({
784
- ...event,
785
- originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
786
- }))
787
- } : {}
788
- });
789
- options.onProgress?.({
790
- currentChunk: index + 1,
791
- totalChunks: chunks.length,
792
- percent: chunks.length === 0 ? 100 : Math.round((index + 1) / chunks.length * 100),
793
- chunkIndex: index,
794
- originalTextRange: input.originalTextRange,
795
- status: "success",
796
- durationMs: Date.now() - startedAt
797
- });
798
- } catch (error) {
799
- options.onProgress?.({
800
- currentChunk: index,
801
- totalChunks: chunks.length,
802
- percent: chunks.length === 0 ? 100 : Math.round(index / chunks.length * 100),
803
- chunkIndex: index,
804
- originalTextRange: input.originalTextRange,
805
- status: "failed",
806
- durationMs: Date.now() - startedAt,
807
- error
808
- });
809
- throw error;
1257
+ const results = new Array(chunks.length);
1258
+ const cachedChunks = new Map((options.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
1259
+ for (const [index, cached] of cachedChunks) {
1260
+ if (index >= 0 && index < chunks.length) results[index] = cached;
1261
+ }
1262
+ const requestedIndices = options.resumeChunkIndices ? new Set(options.resumeChunkIndices.filter((index) => index >= 0 && index < chunks.length)) : void 0;
1263
+ const shouldSynthesize = (index) => !cachedChunks.has(index) && (requestedIndices === void 0 || requestedIndices.has(index));
1264
+ const jobScope = chunks.length > 1 || options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs) : void 0;
1265
+ fallbackJobScope = jobScope;
1266
+ const failedIndices = /* @__PURE__ */ new Set();
1267
+ let firstError;
1268
+ let completed = [...results].filter((result) => result !== void 0).length;
1269
+ let nextIndex = 0;
1270
+ const concurrency = resolveConcurrency2(options.concurrency, chunks.length);
1271
+ const worker = async () => {
1272
+ while (true) {
1273
+ const index = nextIndex++;
1274
+ if (index >= chunks.length) return;
1275
+ if (!shouldSynthesize(index)) continue;
1276
+ if (failedIndices.size > 0 && options.cancelOnFailure !== false) return;
1277
+ const chunk = chunks[index];
1278
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
1279
+ const sourceNodePath = input.sourceNodePath;
1280
+ const originalTextRange = input.originalTextRange;
1281
+ pending(index, "synthesizing");
1282
+ const startedAt = Date.now();
1283
+ try {
1284
+ const chunkTimeout = options.timeouts?.chunkWithRetriesMs ?? options.timeouts?.perChunkMs;
1285
+ const chunkScope = chunkTimeout !== void 0 || jobScope ? createSafeAbortScope(jobScope?.signal ?? options.signal, chunkTimeout ?? options.timeoutMs) : void 0;
1286
+ const chunkSignal = chunkScope?.signal ?? options.signal;
1287
+ let result;
1288
+ try {
1289
+ result = await retryableSynthesis(
1290
+ () => client.synthesizeSsml(input.ssml, {
1291
+ outputFormat: options.outputFormat,
1292
+ signal: chunkSignal,
1293
+ timeoutMs: options.timeouts?.perChunkMs ?? options.timeoutMs,
1294
+ sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
1295
+ }),
1296
+ options.retryOptions,
1297
+ chunkSignal,
1298
+ (retryAttempt, nextRetryDelayMs) => options.onProgress?.({
1299
+ currentChunk: completed,
1300
+ totalChunks: chunks.length,
1301
+ percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1302
+ chunkIndex: index,
1303
+ originalTextRange: input.originalTextRange,
1304
+ status: "synthesizing",
1305
+ durationMs: Date.now() - startedAt,
1306
+ retryAttempt,
1307
+ nextRetryDelayMs,
1308
+ isRetrying: true
1309
+ })
1310
+ );
1311
+ } catch (error) {
1312
+ if (chunkScope?.timedOut())
1313
+ throw new Error(`Speech synthesis timed out after ${chunkTimeout ?? options.timeoutMs} ms.`);
1314
+ throw error;
1315
+ } finally {
1316
+ chunkScope?.dispose();
1317
+ }
1318
+ results[index] = {
1319
+ ...result,
1320
+ ...input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {},
1321
+ ...sourceNodePath ? {
1322
+ boundaries: result.boundaries?.map((event) => ({
1323
+ ...event,
1324
+ sourceNodePath: [...sourceNodePath],
1325
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1326
+ })),
1327
+ visemes: result.visemes?.map((event) => ({
1328
+ ...event,
1329
+ sourceNodePath: [...sourceNodePath],
1330
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1331
+ })),
1332
+ bookmarks: result.bookmarks?.map((event) => ({
1333
+ ...event,
1334
+ sourceNodePath: [...sourceNodePath],
1335
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1336
+ }))
1337
+ } : {},
1338
+ ...originalTextRange ? {
1339
+ boundaries: result.boundaries?.map((event) => ({
1340
+ ...event,
1341
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1342
+ })),
1343
+ wordBoundary: result.wordBoundary?.map((event) => ({
1344
+ ...event,
1345
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1346
+ })),
1347
+ wordBoundaries: result.wordBoundaries?.map((event) => ({
1348
+ ...event,
1349
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1350
+ })),
1351
+ visemes: result.visemes?.map((event) => ({
1352
+ ...event,
1353
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1354
+ })),
1355
+ bookmarks: result.bookmarks?.map((event) => ({
1356
+ ...event,
1357
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1358
+ }))
1359
+ } : {}
1360
+ };
1361
+ completed += 1;
1362
+ options.onProgress?.({
1363
+ currentChunk: completed,
1364
+ totalChunks: chunks.length,
1365
+ percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1366
+ chunkIndex: index,
1367
+ originalTextRange: input.originalTextRange,
1368
+ status: "success",
1369
+ durationMs: Date.now() - startedAt
1370
+ });
1371
+ } catch (error) {
1372
+ failedIndices.add(index);
1373
+ options.onProgress?.({
1374
+ currentChunk: completed,
1375
+ totalChunks: chunks.length,
1376
+ percent: chunks.length === 0 ? 100 : Math.round(index / chunks.length * 100),
1377
+ chunkIndex: index,
1378
+ originalTextRange: input.originalTextRange,
1379
+ status: "failed",
1380
+ durationMs: Date.now() - startedAt,
1381
+ error
1382
+ });
1383
+ if (options.cancelOnFailure !== false) jobScope?.abort();
1384
+ firstError ?? (firstError = error);
1385
+ return;
1386
+ }
810
1387
  }
1388
+ };
1389
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
1390
+ if (failedIndices.size > 0) {
1391
+ const error = firstError ?? new Error("One or more SSML chunks failed to synthesize.");
1392
+ error.partialResult = {
1393
+ synthesizedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
1394
+ completedChunks: results.flatMap((result, chunkIndex) => result ? [{ ...result, chunkIndex }] : []),
1395
+ pendingChunkIndices: chunks.flatMap((_chunk, chunkIndex) => results[chunkIndex] ? [] : [chunkIndex]),
1396
+ failedChunkIndices: [...failedIndices],
1397
+ totalChunks: chunks.length
1398
+ };
1399
+ throw error;
811
1400
  }
1401
+ const orderedResults = results.filter((result) => result !== void 0);
812
1402
  return {
813
1403
  ok: true,
814
1404
  success: true,
815
1405
  status: "success",
816
- value: mergeSynthesisResults(results, {
817
- format: options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3"
1406
+ value: await mergeSynthesisResults(orderedResults, {
1407
+ format: options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3",
1408
+ signal: jobScope?.signal ?? options.signal,
1409
+ customMerger: options.customMerger,
1410
+ outputMimeType: options.outputMimeType,
1411
+ postMergeValidator: options.postMergeValidator
818
1412
  })
819
1413
  };
820
1414
  } catch (error) {
821
1415
  const synthesisError = toSynthesisError(error);
822
- return failure(synthesisError);
1416
+ return failure(synthesisError, partialResultFrom(error));
1417
+ } finally {
1418
+ fallbackJobScope?.dispose();
823
1419
  }
824
1420
  }
825
1421
  function withValidationSignal(options, signal) {
@@ -840,14 +1436,14 @@ var AzureTtsClient = class {
840
1436
  __privateSet(this, _options, options);
841
1437
  }
842
1438
  async synthesize(ssml) {
843
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1439
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
844
1440
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
845
1441
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
846
- const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs };
1442
+ const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts };
847
1443
  return synthesizeSpeech(ssml, config);
848
1444
  }
849
1445
  async synthesizeSsml(ssml, options = {}) {
850
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1446
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
851
1447
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
852
1448
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
853
1449
  return synthesizeSsml(ssml, {
@@ -857,13 +1453,14 @@ var AzureTtsClient = class {
857
1453
  outputFormat: options.outputFormat ?? outputFormat,
858
1454
  signal: options.signal ?? signal,
859
1455
  timeoutMs: options.timeoutMs ?? timeoutMs,
1456
+ timeouts: options.timeouts ?? timeouts,
860
1457
  sourceNodePath: options.sourceNodePath,
861
1458
  sourceTextSegments: options.sourceTextSegments,
862
1459
  sourceMarkers: options.sourceMarkers
863
1460
  });
864
1461
  }
865
1462
  async synthesizeChunks(chunks, options = {}) {
866
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1463
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
867
1464
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
868
1465
  return synthesizeSsmlChunks(chunks, {
869
1466
  endpoint,
@@ -872,8 +1469,17 @@ var AzureTtsClient = class {
872
1469
  outputFormat: options.outputFormat ?? outputFormat,
873
1470
  signal: options.signal ?? signal,
874
1471
  timeoutMs: options.timeoutMs ?? timeoutMs,
1472
+ timeouts: options.timeouts ?? timeouts,
875
1473
  sourceNodePath: options.sourceNodePath,
876
- onProgress: options.onProgress ?? __privateGet(this, _options).onProgress
1474
+ onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1475
+ concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1476
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
1477
+ cancelOnFailure: options.cancelOnFailure,
1478
+ resumeChunks: options.resumeChunks,
1479
+ resumeChunkIndices: options.resumeChunkIndices,
1480
+ customMerger: options.customMerger,
1481
+ outputMimeType: options.outputMimeType,
1482
+ postMergeValidator: options.postMergeValidator
877
1483
  });
878
1484
  }
879
1485
  async synthesizeSsmlSafe(ssml, options = {}) {
@@ -885,7 +1491,10 @@ var AzureTtsClient = class {
885
1491
  outputFormat: options.outputFormat ?? __privateGet(this, _options).outputFormat,
886
1492
  signal: options.signal ?? __privateGet(this, _options).signal,
887
1493
  timeoutMs: options.timeoutMs ?? __privateGet(this, _options).timeoutMs,
888
- onProgress: options.onProgress ?? __privateGet(this, _options).onProgress
1494
+ timeouts: options.timeouts ?? __privateGet(this, _options).timeouts,
1495
+ onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1496
+ concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1497
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
889
1498
  });
890
1499
  }
891
1500
  async synthesizeSsmlChunksSafe(chunks, options = {}) {
@@ -976,14 +1585,18 @@ async function fetchAzureVoiceCatalog(options) {
976
1585
  voiceCount: sortedVoices.length,
977
1586
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
978
1587
  apiVersion: AZURE_VOICE_API_VERSION,
979
- regions
1588
+ regions,
1589
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1e3).toISOString(),
1590
+ regionDiffs: {}
980
1591
  }
981
1592
  };
982
1593
  }
983
1594
  export {
1595
+ AudioFormatMismatchError,
984
1596
  AzureTtsClient,
985
1597
  AzureTtsError,
986
1598
  AzureTtsSdkError,
1599
+ BatchChunkValidationError,
987
1600
  ChunkValidationError,
988
1601
  DEFAULT_OUTPUT_FORMAT,
989
1602
  MergeError,
@@ -992,6 +1605,8 @@ export {
992
1605
  UnsupportedMergeFormatError,
993
1606
  canMergeAudioFormat,
994
1607
  fetchAzureVoiceCatalog,
1608
+ getRetryAfterDelayMs,
1609
+ inspectAudioSpecification,
995
1610
  mergeAudioBuffers,
996
1611
  mergeSynthesisResults,
997
1612
  resolveMergeAudioFormat,