@ssml-builder-js/azure-tts-client 2.16.0 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/synthesis.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  SynthesisTimeoutError,
9
9
  toSynthesisError,
10
10
  UnsupportedMergeFormatError,
11
+ getRetryAfterDelayMs,
11
12
  } from "./errors.ts";
12
13
  import { DEFAULT_OUTPUT_FORMAT, resolveMimeType, type AzureTtsOutputFormat } from "./outputFormats.ts";
13
14
  import { createSpeechConfig } from "./speechConfig.ts";
@@ -19,6 +20,9 @@ import type {
19
20
  SynthesisProgressEvent,
20
21
  TtsConfig,
21
22
  RetryOptions,
23
+ CustomAudioMerger,
24
+ PostMergeValidator,
25
+ ChunkExecutionState,
22
26
  } from "./types.ts";
23
27
 
24
28
  export type MergeAudioFormat = "wav" | "mp3" | "raw";
@@ -31,15 +35,36 @@ export interface MergeAudioOptions {
31
35
 
32
36
  export type InputAudioSpecs = AudioSpecification[];
33
37
 
34
- export interface CustomMergerContext {
35
- format: string;
36
- outputMimeType: string;
37
- inputSpecs: InputAudioSpecs;
38
- signal: AbortSignal;
38
+ /**
39
+ * Creates a deterministic, runtime-independent fingerprint for a synthesis chunk.
40
+ * The complete SSML is included so changes to voice, language, prosody, or text
41
+ * invalidate a cached result even when those settings are nested in the markup.
42
+ */
43
+ export function computeChunkFingerprint(ssml: string, outputFormat = DEFAULT_OUTPUT_FORMAT): string {
44
+ const readAttribute = (name: string): string => {
45
+ const pattern = new RegExp(`(?:${name})\\s*=\\s*[\\"']([^\\"']*)`, "gi");
46
+ return [...ssml.matchAll(pattern)].map((match) => match[1] ?? "").join("|");
47
+ };
48
+ const payload = JSON.stringify({
49
+ ssml,
50
+ outputFormat,
51
+ voice: readAttribute("(?:name|voice)"),
52
+ language: readAttribute("(?:xml:lang|lang)"),
53
+ rate: readAttribute("rate"),
54
+ pitch: readAttribute("pitch"),
55
+ });
56
+ let hash = 0xcbf29ce484222325n;
57
+ const mask = 0xffffffffffffffffn;
58
+ for (let index = 0; index < payload.length; index += 1) {
59
+ hash ^= BigInt(payload.charCodeAt(index));
60
+ hash = (hash * 0x100000001b3n) & mask;
61
+ }
62
+ return `fnv1a64-${hash.toString(16).padStart(16, "0")}`;
39
63
  }
40
64
 
41
65
  export interface MergeSynthesisOptions extends MergeAudioOptions {
42
- customMerger?: (buffers: ArrayBuffer[], context: CustomMergerContext) => Promise<ArrayBuffer> | ArrayBuffer;
66
+ customMerger?: CustomAudioMerger;
67
+ postMergeValidator?: PostMergeValidator;
43
68
  }
44
69
 
45
70
  type AsyncMergeSynthesisOptions = MergeSynthesisOptions & {
@@ -99,9 +124,11 @@ function parseWav(buffer: ArrayBuffer): ParsedWav {
99
124
  return { chunks, data, format };
100
125
  }
101
126
 
102
- function formatNumber(format: string, pattern: RegExp, fallback: number): number {
103
- const match = pattern.exec(format);
104
- return match?.[1] ? Number(match[1]) : fallback;
127
+ function formatSampleRate(format: string): number {
128
+ const match = /(?:^|-)(\d+)(khz|hz)(?:-|$)/i.exec(format);
129
+ if (!match?.[1] || !match[2]) return 0;
130
+ const value = Number(match[1]);
131
+ return match[2].toLowerCase() === "khz" ? value * 1000 : value;
105
132
  }
106
133
 
107
134
  function formatChannels(format: string, fallback: number): number {
@@ -111,7 +138,7 @@ function formatChannels(format: string, fallback: number): number {
111
138
  }
112
139
 
113
140
  function formatAudioSpecification(format: string): AudioSpecification {
114
- const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
141
+ const sampleRate = formatSampleRate(format);
115
142
  const channels = formatChannels(format, 0);
116
143
  const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
117
144
  const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1000 : undefined;
@@ -121,9 +148,27 @@ function formatAudioSpecification(format: string): AudioSpecification {
121
148
  ? "opus"
122
149
  : /silk/i.test(format)
123
150
  ? "silk"
124
- : /pcm|mulaw|alaw|siren/i.test(format)
125
- ? "pcm"
126
- : "unknown";
151
+ : /mulaw|mu-law/i.test(format)
152
+ ? "mulaw"
153
+ : /alaw|a-law/i.test(format)
154
+ ? "alaw"
155
+ : /siren/i.test(format)
156
+ ? "siren"
157
+ : /pcm/i.test(format)
158
+ ? "pcm"
159
+ : "unknown";
160
+ const bitDepthMatch = /(\d+)bit/i.exec(format);
161
+ const container = /(?:wav|wave|riff)/i.test(format)
162
+ ? "riff-wave"
163
+ : /mp3|mpeg/i.test(format)
164
+ ? "mp3-raw"
165
+ : /ogg/i.test(format)
166
+ ? "ogg"
167
+ : /webm/i.test(format)
168
+ ? "webm"
169
+ : /raw/i.test(format)
170
+ ? "raw"
171
+ : undefined;
127
172
  return {
128
173
  format,
129
174
  mimeType: resolveMimeType(format),
@@ -131,7 +176,10 @@ function formatAudioSpecification(format: string): AudioSpecification {
131
176
  sampleRate,
132
177
  channels,
133
178
  ...(bitrate ? { bitrate } : {}),
134
- isCompressed: codec === "mp3" || codec === "opus" || codec === "silk",
179
+ ...(bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {}),
180
+ ...(container ? { container } : {}),
181
+ isVbr: /vbr/i.test(format),
182
+ isCompressed: codec !== "pcm" && codec !== "unknown",
135
183
  };
136
184
  }
137
185
 
@@ -167,6 +215,8 @@ function parseMp3Specification(buffer: ArrayBuffer, format: string): AudioSpecif
167
215
  sampleRate,
168
216
  channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
169
217
  bitrate: bitrateKbps * 1000,
218
+ container: "mp3-raw",
219
+ isVbr: false,
170
220
  isCompressed: true,
171
221
  };
172
222
  }
@@ -183,18 +233,47 @@ export function inspectAudioSpecification(buffer: ArrayBuffer, format: string):
183
233
  const channels = view.getUint16(2, true);
184
234
  const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
185
235
  const formatCode = view.getUint16(0, true);
236
+ const namedCodec = formatAudioSpecification(format).codec;
237
+ const codec: AudioSpecification["codec"] =
238
+ formatCode === 1
239
+ ? "pcm"
240
+ : formatCode === 6
241
+ ? "alaw"
242
+ : formatCode === 7
243
+ ? "mulaw"
244
+ : namedCodec === "siren"
245
+ ? "siren"
246
+ : "unknown";
186
247
  return {
187
248
  format,
188
249
  mimeType: "audio/wav",
189
- codec: formatCode === 1 ? "pcm" : "unknown",
250
+ codec,
190
251
  sampleRate,
191
252
  channels,
192
253
  ...(sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {}),
193
- isCompressed: formatCode !== 1,
254
+ bitDepth: bitsPerSample,
255
+ container: "riff-wave",
256
+ isVbr: false,
257
+ isCompressed: codec !== "pcm" && codec !== "unknown",
194
258
  };
195
259
  }
196
260
  if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
197
- return formatAudioSpecification(format);
261
+ const specification = formatAudioSpecification(format);
262
+ if (specification.container === "raw") validateRawAudioBuffer(buffer, specification);
263
+ return specification;
264
+ }
265
+
266
+ function validateRawAudioBuffer(buffer: ArrayBuffer, specification: AudioSpecification): void {
267
+ if (specification.sampleRate <= 0 || specification.channels <= 0 || specification.bitDepth === undefined) {
268
+ throw new Error(`RAW audio format "${specification.format}" does not define a complete audio specification.`);
269
+ }
270
+ if (specification.codec === "siren" || specification.codec === "silk") return;
271
+ const bytesPerFrame = specification.channels * Math.ceil(specification.bitDepth / 8);
272
+ if (bytesPerFrame <= 0 || buffer.byteLength % bytesPerFrame !== 0) {
273
+ throw new Error(
274
+ `RAW audio buffer size ${buffer.byteLength} is not aligned to ${bytesPerFrame}-byte audio frames for "${specification.format}".`,
275
+ );
276
+ }
198
277
  }
199
278
 
200
279
  function validateAudioSpecifications(specs: readonly AudioSpecification[]): void {
@@ -204,7 +283,11 @@ function validateAudioSpecifications(specs: readonly AudioSpecification[]): void
204
283
  (spec) =>
205
284
  spec.sampleRate !== first.sampleRate ||
206
285
  spec.channels !== first.channels ||
207
- (first.bitrate !== undefined && spec.bitrate !== undefined && spec.bitrate !== first.bitrate),
286
+ spec.codec !== first.codec ||
287
+ (first.bitrate !== undefined && spec.bitrate !== undefined && spec.bitrate !== first.bitrate) ||
288
+ (first.bitDepth !== undefined && spec.bitDepth !== undefined && spec.bitDepth !== first.bitDepth) ||
289
+ (first.container !== undefined && spec.container !== undefined && spec.container !== first.container) ||
290
+ (first.isVbr !== undefined && spec.isVbr !== undefined && spec.isVbr !== first.isVbr),
208
291
  );
209
292
  if (mismatch)
210
293
  throw new AudioFormatMismatchError(
@@ -303,6 +386,43 @@ function isRawFormat(format: string): boolean {
303
386
  return /^raw(?:-|$)/i.test(format);
304
387
  }
305
388
 
389
+ function validateMergedAudioBuffer(
390
+ merged: ArrayBuffer,
391
+ format: string,
392
+ buffers: readonly ArrayBuffer[],
393
+ inputSpecs: readonly AudioSpecification[],
394
+ outputMimeType: string,
395
+ ): AudioSpecification {
396
+ if (!(merged instanceof ArrayBuffer) || merged.byteLength === 0) {
397
+ throw new MergeError("The custom audio merger returned an empty or invalid audio buffer.");
398
+ }
399
+ const specification = inspectAudioSpecification(merged, format);
400
+ if (!outputMimeType.trim()) throw new MergeError("The custom audio merger output MIME type cannot be empty.");
401
+ const firstInput = inputSpecs[0];
402
+ if (
403
+ firstInput &&
404
+ (specification.sampleRate !== firstInput.sampleRate ||
405
+ specification.channels !== firstInput.channels ||
406
+ specification.codec !== firstInput.codec ||
407
+ (firstInput.bitDepth !== undefined && specification.bitDepth !== firstInput.bitDepth))
408
+ ) {
409
+ throw new AudioFormatMismatchError("The custom audio merger returned an incompatible audio stream.", [
410
+ ...inputSpecs,
411
+ specification,
412
+ ]);
413
+ }
414
+ if (isRawFormat(format)) {
415
+ const expectedSize = buffers.reduce((total, input) => total + input.byteLength, 0);
416
+ if (merged.byteLength !== expectedSize) {
417
+ throw new MergeError(
418
+ `The custom audio merger returned ${merged.byteLength} bytes; ${expectedSize} were expected.`,
419
+ );
420
+ }
421
+ validateRawAudioBuffer(merged, specification);
422
+ }
423
+ return specification;
424
+ }
425
+
306
426
  /** Returns whether the named output format can be safely concatenated without re-multiplexing. */
307
427
  export function resolveMergeAudioFormat(format: string): MergeAudioFormat | undefined {
308
428
  if (isWavFormat(format)) return "wav";
@@ -362,7 +482,7 @@ function closeSpeechResources(speechConfig: SpeechSDK.SpeechConfig, synthesizer:
362
482
 
363
483
  const ticksToMilliseconds = (ticks: number): number => Math.max(0, ticks) / 10_000;
364
484
 
365
- export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<SsmlSynthesisResult> {
485
+ async function synthesizeSsmlOnce(ssml: string, config: TtsConfig): Promise<SsmlSynthesisResult> {
366
486
  if (config.signal?.aborted) {
367
487
  throw new SynthesisCancelledError();
368
488
  }
@@ -448,9 +568,7 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
448
568
  };
449
569
  }
450
570
  if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) {
451
- const unmapped: { mappingStatus: "unmapped" } = { mappingStatus: "unmapped" };
452
- Object.defineProperty(unmapped, "mappingStatus", { value: "unmapped", enumerable: false });
453
- return unmapped;
571
+ return { mappingStatus: "unmapped" };
454
572
  }
455
573
  const value = text ?? "";
456
574
  let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? (offsetHint as number) : -1;
@@ -522,6 +640,13 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
522
640
  rejectWithError(err);
523
641
  return;
524
642
  }
643
+ let audioSpec: AudioSpecification;
644
+ try {
645
+ audioSpec = inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT);
646
+ } catch (error) {
647
+ rejectWithError(error);
648
+ return;
649
+ }
525
650
  settled = true;
526
651
  cleanup();
527
652
  closeResources();
@@ -546,8 +671,13 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
546
671
  ...(config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {}),
547
672
  ...(requestId ? { requestId } : {}),
548
673
  } as T;
549
- if (event.mappingStatus === "unmapped")
674
+ if (event.mappingStatus === "unmapped") {
550
675
  Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
676
+ Object.defineProperty(mapped, "toJSON", {
677
+ value: () => ({ ...mapped, mappingStatus: "unmapped" }),
678
+ enumerable: false,
679
+ });
680
+ }
551
681
  return mapped;
552
682
  };
553
683
  const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
@@ -556,8 +686,8 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
556
686
  resolve({
557
687
  audioData: result.audioData,
558
688
  durationMs,
559
- audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
560
- mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
689
+ audioSpec,
690
+ mimeType: config.outputMimeType ?? resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
561
691
  ...(config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {}),
562
692
  ...(requestId ? { requestId } : {}),
563
693
  ...(sourceBoundaries.length > 0
@@ -573,10 +703,11 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
573
703
  abortHandler = () => rejectWithError(new SynthesisCancelledError());
574
704
  config.signal.addEventListener("abort", abortHandler, { once: true });
575
705
  }
576
- if (config.timeoutMs !== undefined && config.timeoutMs > 0) {
706
+ const timeoutMs = config.timeouts?.perChunkMs ?? config.timeouts?.totalJobMs ?? config.timeoutMs;
707
+ if (timeoutMs !== undefined && timeoutMs > 0) {
577
708
  timeout = setTimeout(
578
- () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeoutMs} ms.`)),
579
- config.timeoutMs,
709
+ () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
710
+ timeoutMs,
580
711
  );
581
712
  }
582
713
  synthesizer.speakSsmlAsync(ssml, cb, rejectWithError);
@@ -597,7 +728,9 @@ function isRetryableSynthesisError(error: unknown): boolean {
597
728
  return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
598
729
  }
599
730
 
600
- function retryDelay(options: RetryOptions, retryAttempt: number): number {
731
+ function retryDelay(options: RetryOptions, retryAttempt: number, error?: unknown): number {
732
+ const retryAfterMs = getRetryAfterDelayMs(error);
733
+ if (retryAfterMs !== undefined) return retryAfterMs;
601
734
  const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
602
735
  return Math.floor(Math.random() * (base + 1));
603
736
  }
@@ -633,39 +766,147 @@ async function synthesizeWithRetry(
633
766
  config: TtsConfig,
634
767
  retryOptions: RetryOptions | undefined,
635
768
  onRetry: (retryAttempt: number, nextRetryDelayMs: number) => void,
769
+ deadlineAtMs?: number,
636
770
  ): Promise<SsmlSynthesisResult> {
637
771
  const options = retryOptions
638
772
  ? {
639
773
  maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
640
774
  initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
641
775
  maxDelayMs: Math.max(0, retryOptions.maxDelayMs),
776
+ shouldRetry: retryOptions.shouldRetry,
642
777
  }
643
778
  : undefined;
644
779
  let attempt = 0;
645
780
  while (true) {
646
781
  if (config.signal?.aborted) throw new SynthesisCancelledError();
647
782
  try {
648
- return await synthesizeSsml(ssml, config);
783
+ return await synthesizeSsmlOnce(ssml, config);
649
784
  } catch (error) {
650
- if (!options || attempt >= options.maxRetries || !isRetryableSynthesisError(error)) throw error;
785
+ if (
786
+ !options ||
787
+ attempt >= options.maxRetries ||
788
+ !(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error))
789
+ )
790
+ throw error;
651
791
  attempt += 1;
652
- const delayMs = retryDelay(options, attempt);
792
+ const delayMs = retryDelay(options, attempt, error);
793
+ const remainingMs = deadlineAtMs === undefined ? undefined : Math.max(0, deadlineAtMs - Date.now());
794
+ if (
795
+ getRetryAfterDelayMs(error) !== undefined &&
796
+ (delayMs > options.maxDelayMs || (remainingMs !== undefined && delayMs > remainingMs))
797
+ ) {
798
+ throw new SynthesisTimeoutError(
799
+ remainingMs === undefined
800
+ ? `Retry-After exceeded maxDelayMs (${options.maxDelayMs} ms).`
801
+ : `Retry-After exceeded the remaining total job timeout (${remainingMs} ms).`,
802
+ );
803
+ }
653
804
  onRetry(attempt, delayMs);
654
- await waitForRetry(delayMs, config.signal);
805
+ await waitForRetry(Math.min(delayMs, remainingMs ?? delayMs), config.signal);
655
806
  }
656
807
  }
657
808
  }
658
809
 
810
+ /** Synthesizes one SSML document, optionally retrying transient failures within the job deadline. */
811
+ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<SsmlSynthesisResult> {
812
+ const totalJobMs = config.timeouts?.totalJobMs;
813
+ const deadlineAtMs = totalJobMs !== undefined && totalJobMs > 0 ? Date.now() + totalJobMs : undefined;
814
+ if (!config.retryOptions) return synthesizeSsmlOnce(ssml, config);
815
+ return synthesizeWithRetry(ssml, config, config.retryOptions, () => undefined, deadlineAtMs);
816
+ }
817
+
818
+ interface AbortScope {
819
+ signal: AbortSignal;
820
+ timedOut: () => boolean;
821
+ dispose: () => void;
822
+ abort: () => void;
823
+ }
824
+
825
+ function createAbortScope(parent: AbortSignal | undefined, timeoutMs: number | undefined): AbortScope {
826
+ const controller = new AbortController();
827
+ let didTimeout = false;
828
+ const onAbort = () => controller.abort();
829
+ if (parent?.aborted) controller.abort();
830
+ parent?.addEventListener("abort", onAbort, { once: true });
831
+ const timer =
832
+ timeoutMs !== undefined && timeoutMs > 0
833
+ ? setTimeout(() => {
834
+ didTimeout = true;
835
+ controller.abort();
836
+ }, timeoutMs)
837
+ : undefined;
838
+ return {
839
+ signal: controller.signal,
840
+ timedOut: () => didTimeout,
841
+ dispose: () => {
842
+ if (timer) clearTimeout(timer);
843
+ parent?.removeEventListener("abort", onAbort);
844
+ },
845
+ abort: () => controller.abort(),
846
+ };
847
+ }
848
+
849
+ async function synthesizeChunkWithTimeout(
850
+ ssml: string,
851
+ config: TtsConfig,
852
+ retryOptions: RetryOptions | undefined,
853
+ timeoutMs: number | undefined,
854
+ onRetry: (retryAttempt: number, nextRetryDelayMs: number) => void,
855
+ deadlineAtMs?: number,
856
+ ): Promise<SsmlSynthesisResult> {
857
+ const scope = createAbortScope(config.signal, timeoutMs);
858
+ try {
859
+ return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry, deadlineAtMs);
860
+ } catch (error) {
861
+ if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
862
+ throw error;
863
+ } finally {
864
+ scope.dispose();
865
+ }
866
+ }
867
+
659
868
  /** Synthesizes chunks with bounded concurrency, retries transient failures, and merges in chunk order. */
660
869
  export async function synthesizeSsmlChunks(
661
870
  chunks: readonly (SsmlSynthesisChunk | string)[],
662
871
  config: TtsConfig,
663
872
  ): Promise<SsmlSynthesisResult> {
664
- const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
665
873
  const totalChunks = chunks.length;
874
+ const inputs = chunks.map((chunk) => (typeof chunk === "string" ? { ssml: chunk } : chunk));
875
+ const fingerprints = inputs.map((chunk) =>
876
+ computeChunkFingerprint(chunk.ssml, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
877
+ );
878
+ const results: Array<SsmlSynthesisResult | undefined> = new Array(totalChunks);
879
+ const cachedChunks = new Map((config.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
880
+ const invalidCachedIndices = new Set<number>();
881
+ const chunkStates: ChunkExecutionState[] = inputs.map((_chunk, chunkIndex) => ({
882
+ chunkIndex,
883
+ status: "pending",
884
+ canResume: true,
885
+ }));
886
+ for (const [index, cached] of cachedChunks) {
887
+ if (index < 0 || index >= totalChunks) continue;
888
+ const isValid = config.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index];
889
+ if (isValid) {
890
+ results[index] = { ...cached };
891
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
892
+ } else {
893
+ invalidCachedIndices.add(index);
894
+ }
895
+ }
896
+ const requestedIndices = config.resumeChunkIndices
897
+ ? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks))
898
+ : undefined;
899
+ const shouldSynthesize = (index: number): boolean =>
900
+ (!cachedChunks.has(index) || invalidCachedIndices.has(index)) &&
901
+ (requestedIndices === undefined || requestedIndices.has(index) || invalidCachedIndices.has(index));
902
+ const jobStartedAt = Date.now();
903
+ const jobDeadlineAt =
904
+ config.timeouts?.totalJobMs !== undefined && config.timeouts.totalJobMs > 0
905
+ ? jobStartedAt + config.timeouts.totalJobMs
906
+ : undefined;
907
+ const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
666
908
  const report = (event: SynthesisProgressEvent): void => config.onProgress?.(event);
667
- for (const [index, chunk] of chunks.entries()) {
668
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
909
+ for (const [index, input] of inputs.entries()) {
669
910
  report({
670
911
  currentChunk: index,
671
912
  totalChunks,
@@ -676,15 +917,18 @@ export async function synthesizeSsmlChunks(
676
917
  durationMs: 0,
677
918
  });
678
919
  }
679
- let completed = 0;
920
+ let completed = [...results].filter((result) => result !== undefined).length;
680
921
  let nextIndex = 0;
681
922
  const concurrency = resolveConcurrency(config.concurrency, chunks.length);
923
+ let firstError: unknown;
924
+ const failedIndices = new Set<number>();
682
925
  const worker = async (): Promise<void> => {
683
926
  while (true) {
684
927
  const index = nextIndex++;
685
928
  if (index >= chunks.length) return;
686
- const chunk = chunks[index];
687
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
929
+ if (!shouldSynthesize(index)) continue;
930
+ if (firstError && config.cancelOnFailure !== false) return;
931
+ const input = inputs[index];
688
932
  report({
689
933
  currentChunk: completed,
690
934
  totalChunks,
@@ -696,10 +940,11 @@ export async function synthesizeSsmlChunks(
696
940
  });
697
941
  const startedAt = Date.now();
698
942
  try {
699
- const result = await synthesizeWithRetry(
943
+ const result = await synthesizeChunkWithTimeout(
700
944
  input.ssml,
701
945
  {
702
946
  ...config,
947
+ signal: scope.signal,
703
948
  ...(input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {}),
704
949
  ...((input.sourceNodePath ?? config.sourceNodePath)
705
950
  ? { sourceNodePath: [...(input.sourceNodePath ?? config.sourceNodePath ?? [])] }
@@ -710,6 +955,7 @@ export async function synthesizeSsmlChunks(
710
955
  onProgress: undefined,
711
956
  },
712
957
  config.retryOptions,
958
+ config.timeouts?.chunkWithRetriesMs ?? config.timeouts?.perChunkMs ?? config.timeoutMs,
713
959
  (retryAttempt, nextRetryDelayMs) =>
714
960
  report({
715
961
  currentChunk: completed,
@@ -723,8 +969,10 @@ export async function synthesizeSsmlChunks(
723
969
  nextRetryDelayMs,
724
970
  isRetrying: true,
725
971
  }),
972
+ jobDeadlineAt,
726
973
  );
727
974
  results[index] = result;
975
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result };
728
976
  completed += 1;
729
977
  report({
730
978
  currentChunk: completed,
@@ -736,6 +984,18 @@ export async function synthesizeSsmlChunks(
736
984
  durationMs: Date.now() - startedAt,
737
985
  });
738
986
  } catch (error) {
987
+ const wasCancelled = firstError !== undefined || (scope.signal.aborted && !scope.timedOut());
988
+ firstError ??= scope.timedOut()
989
+ ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`)
990
+ : error;
991
+ if (!wasCancelled) failedIndices.add(index);
992
+ chunkStates[index] = {
993
+ chunkIndex: index,
994
+ status: wasCancelled ? "cancelled" : "failed",
995
+ isOriginalFailure: !wasCancelled,
996
+ canResume: true,
997
+ error: error as ChunkExecutionState["error"],
998
+ };
739
999
  report({
740
1000
  currentChunk: completed,
741
1001
  totalChunks,
@@ -746,16 +1006,53 @@ export async function synthesizeSsmlChunks(
746
1006
  durationMs: Date.now() - startedAt,
747
1007
  error,
748
1008
  });
749
- throw error;
1009
+ if (config.cancelOnFailure !== false) scope.abort();
1010
+ return;
750
1011
  }
751
1012
  }
752
1013
  };
753
- await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
754
- const orderedResults = results.filter((result): result is SsmlSynthesisResult => result !== undefined);
755
- return mergeSynthesisResults(orderedResults, {
756
- format: (config.outputFormat ?? DEFAULT_OUTPUT_FORMAT) as AzureTtsOutputFormat,
757
- signal: config.signal,
758
- });
1014
+ try {
1015
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
1016
+ if (firstError) throw firstError;
1017
+ const orderedResults = results.filter((result): result is SsmlSynthesisResult => result !== undefined);
1018
+ return await mergeSynthesisResults(orderedResults, {
1019
+ format: (config.outputFormat ?? DEFAULT_OUTPUT_FORMAT) as AzureTtsOutputFormat,
1020
+ signal: scope.signal,
1021
+ customMerger: config.customMerger,
1022
+ outputMimeType: config.outputMimeType,
1023
+ postMergeValidator: config.postMergeValidator,
1024
+ });
1025
+ } catch (error) {
1026
+ if (firstError && config.cancelOnFailure !== false) {
1027
+ for (const [chunkIndex, state] of chunkStates.entries()) {
1028
+ if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
1029
+ chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
1030
+ }
1031
+ }
1032
+ }
1033
+ const synthesizedChunks = results.flatMap((result, chunkIndex) =>
1034
+ result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : [],
1035
+ );
1036
+ const partial = {
1037
+ synthesizedChunks,
1038
+ completedChunks: synthesizedChunks,
1039
+ pendingChunkIndices: chunkStates.flatMap((state) =>
1040
+ state.status === "pending" || state.status === "cancelled" || state.status === "failed"
1041
+ ? [state.chunkIndex]
1042
+ : [],
1043
+ ),
1044
+ failedChunkIndices: [...failedIndices],
1045
+ cancelledChunkIndices: chunkStates
1046
+ .filter((state) => state.status === "cancelled")
1047
+ .map((state) => state.chunkIndex),
1048
+ chunkStates,
1049
+ totalChunks,
1050
+ };
1051
+ if (error && typeof error === "object") (error as { partialResult?: unknown }).partialResult = partial;
1052
+ throw error;
1053
+ } finally {
1054
+ scope.dispose();
1055
+ }
759
1056
  }
760
1057
 
761
1058
  /** Concatenates audio buffers and shifts all synchronization events by prior chunk durations. */
@@ -845,6 +1142,10 @@ export function mergeSynthesisResults(
845
1142
  results: readonly SsmlSynthesisResult[],
846
1143
  options: AsyncMergeSynthesisOptions,
847
1144
  ): Promise<MergedSynthesisResult>;
1145
+ export function mergeSynthesisResults(
1146
+ results: readonly SsmlSynthesisResult[],
1147
+ options: MergeSynthesisOptions,
1148
+ ): MergedSynthesisResult | Promise<MergedSynthesisResult>;
848
1149
  export function mergeSynthesisResults(
849
1150
  results: readonly SsmlSynthesisResult[],
850
1151
  options: MergeAudioOptions,
@@ -874,19 +1175,26 @@ export function mergeSynthesisResults(
874
1175
  )
875
1176
  .then((merged) => {
876
1177
  if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
877
- if (
878
- !(merged instanceof ArrayBuffer) ||
879
- (buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
880
- )
881
- throw new MergeError("The custom audio merger returned an invalid audio buffer.");
882
1178
  if (signal.aborted) throw new SynthesisCancelledError();
883
- return createMergedResult(
884
- results,
1179
+ const mergedSpec = validateMergedAudioBuffer(
885
1180
  merged,
886
1181
  format,
887
- inspectAudioSpecification(merged, format),
888
- resolvedOptions.outputMimeType,
1182
+ buffers,
1183
+ inputSpecs,
1184
+ resolvedOptions.outputMimeType ?? resolveMimeType(format),
889
1185
  );
1186
+ const result = createMergedResult(results, merged, format, mergedSpec, resolvedOptions.outputMimeType);
1187
+ return Promise.resolve(
1188
+ resolvedOptions.postMergeValidator?.(result, {
1189
+ format,
1190
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
1191
+ inputSpecs,
1192
+ signal,
1193
+ }),
1194
+ ).then((valid) => {
1195
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1196
+ return result;
1197
+ });
890
1198
  })
891
1199
  .catch((error: unknown) => {
892
1200
  if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
@@ -895,13 +1203,28 @@ export function mergeSynthesisResults(
895
1203
  });
896
1204
  }
897
1205
  try {
898
- return createMergedResult(
1206
+ const result = createMergedResult(
899
1207
  results,
900
1208
  mergeAudioBuffers(buffers, { format }),
901
1209
  format,
902
1210
  inputSpecs[0],
903
1211
  resolvedOptions.outputMimeType,
904
1212
  );
1213
+ if (resolvedOptions.postMergeValidator) {
1214
+ const validation = resolvedOptions.postMergeValidator(result, {
1215
+ format,
1216
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
1217
+ inputSpecs,
1218
+ signal,
1219
+ });
1220
+ if (validation instanceof Promise)
1221
+ return validation.then((valid) => {
1222
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1223
+ return result;
1224
+ });
1225
+ if (validation === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1226
+ }
1227
+ return result;
905
1228
  } catch (error) {
906
1229
  if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
907
1230
  throw error;