@ssml-builder-js/azure-tts-client 2.16.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/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,8 @@ import type {
19
20
  SynthesisProgressEvent,
20
21
  TtsConfig,
21
22
  RetryOptions,
23
+ CustomAudioMerger,
24
+ PostMergeValidator,
22
25
  } from "./types.ts";
23
26
 
24
27
  export type MergeAudioFormat = "wav" | "mp3" | "raw";
@@ -31,15 +34,9 @@ export interface MergeAudioOptions {
31
34
 
32
35
  export type InputAudioSpecs = AudioSpecification[];
33
36
 
34
- export interface CustomMergerContext {
35
- format: string;
36
- outputMimeType: string;
37
- inputSpecs: InputAudioSpecs;
38
- signal: AbortSignal;
39
- }
40
-
41
37
  export interface MergeSynthesisOptions extends MergeAudioOptions {
42
- customMerger?: (buffers: ArrayBuffer[], context: CustomMergerContext) => Promise<ArrayBuffer> | ArrayBuffer;
38
+ customMerger?: CustomAudioMerger;
39
+ postMergeValidator?: PostMergeValidator;
43
40
  }
44
41
 
45
42
  type AsyncMergeSynthesisOptions = MergeSynthesisOptions & {
@@ -124,6 +121,18 @@ function formatAudioSpecification(format: string): AudioSpecification {
124
121
  : /pcm|mulaw|alaw|siren/i.test(format)
125
122
  ? "pcm"
126
123
  : "unknown";
124
+ const bitDepthMatch = /(\d+)bit/i.exec(format);
125
+ const container = /(?:wav|wave|riff)/i.test(format)
126
+ ? "riff-wave"
127
+ : /mp3|mpeg/i.test(format)
128
+ ? "mp3-raw"
129
+ : /ogg/i.test(format)
130
+ ? "ogg"
131
+ : /webm/i.test(format)
132
+ ? "webm"
133
+ : /raw/i.test(format)
134
+ ? "raw"
135
+ : undefined;
127
136
  return {
128
137
  format,
129
138
  mimeType: resolveMimeType(format),
@@ -131,6 +140,9 @@ function formatAudioSpecification(format: string): AudioSpecification {
131
140
  sampleRate,
132
141
  channels,
133
142
  ...(bitrate ? { bitrate } : {}),
143
+ ...(bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {}),
144
+ ...(container ? { container } : {}),
145
+ isVbr: /vbr/i.test(format),
134
146
  isCompressed: codec === "mp3" || codec === "opus" || codec === "silk",
135
147
  };
136
148
  }
@@ -167,6 +179,8 @@ function parseMp3Specification(buffer: ArrayBuffer, format: string): AudioSpecif
167
179
  sampleRate,
168
180
  channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
169
181
  bitrate: bitrateKbps * 1000,
182
+ container: "mp3-raw",
183
+ isVbr: false,
170
184
  isCompressed: true,
171
185
  };
172
186
  }
@@ -190,6 +204,9 @@ export function inspectAudioSpecification(buffer: ArrayBuffer, format: string):
190
204
  sampleRate,
191
205
  channels,
192
206
  ...(sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {}),
207
+ bitDepth: bitsPerSample,
208
+ container: "riff-wave",
209
+ isVbr: false,
193
210
  isCompressed: formatCode !== 1,
194
211
  };
195
212
  }
@@ -204,7 +221,10 @@ function validateAudioSpecifications(specs: readonly AudioSpecification[]): void
204
221
  (spec) =>
205
222
  spec.sampleRate !== first.sampleRate ||
206
223
  spec.channels !== first.channels ||
207
- (first.bitrate !== undefined && spec.bitrate !== undefined && spec.bitrate !== first.bitrate),
224
+ (first.bitrate !== undefined && spec.bitrate !== undefined && spec.bitrate !== first.bitrate) ||
225
+ (first.bitDepth !== undefined && spec.bitDepth !== undefined && spec.bitDepth !== first.bitDepth) ||
226
+ (first.container !== undefined && spec.container !== undefined && spec.container !== first.container) ||
227
+ (first.isVbr !== undefined && spec.isVbr !== undefined && spec.isVbr !== first.isVbr),
208
228
  );
209
229
  if (mismatch)
210
230
  throw new AudioFormatMismatchError(
@@ -448,9 +468,7 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
448
468
  };
449
469
  }
450
470
  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;
471
+ return { mappingStatus: "unmapped" };
454
472
  }
455
473
  const value = text ?? "";
456
474
  let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? (offsetHint as number) : -1;
@@ -546,8 +564,13 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
546
564
  ...(config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {}),
547
565
  ...(requestId ? { requestId } : {}),
548
566
  } as T;
549
- if (event.mappingStatus === "unmapped")
567
+ if (event.mappingStatus === "unmapped") {
550
568
  Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
569
+ Object.defineProperty(mapped, "toJSON", {
570
+ value: () => ({ ...mapped, mappingStatus: "unmapped" }),
571
+ enumerable: false,
572
+ });
573
+ }
551
574
  return mapped;
552
575
  };
553
576
  const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
@@ -573,10 +596,11 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
573
596
  abortHandler = () => rejectWithError(new SynthesisCancelledError());
574
597
  config.signal.addEventListener("abort", abortHandler, { once: true });
575
598
  }
576
- if (config.timeoutMs !== undefined && config.timeoutMs > 0) {
599
+ const timeoutMs = config.timeouts?.perChunkMs ?? config.timeoutMs;
600
+ if (timeoutMs !== undefined && timeoutMs > 0) {
577
601
  timeout = setTimeout(
578
- () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeoutMs} ms.`)),
579
- config.timeoutMs,
602
+ () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
603
+ timeoutMs,
580
604
  );
581
605
  }
582
606
  synthesizer.speakSsmlAsync(ssml, cb, rejectWithError);
@@ -597,7 +621,9 @@ function isRetryableSynthesisError(error: unknown): boolean {
597
621
  return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
598
622
  }
599
623
 
600
- function retryDelay(options: RetryOptions, retryAttempt: number): number {
624
+ function retryDelay(options: RetryOptions, retryAttempt: number, error?: unknown): number {
625
+ const retryAfterMs = getRetryAfterDelayMs(error);
626
+ if (retryAfterMs !== undefined) return retryAfterMs;
601
627
  const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
602
628
  return Math.floor(Math.random() * (base + 1));
603
629
  }
@@ -639,6 +665,7 @@ async function synthesizeWithRetry(
639
665
  maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
640
666
  initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
641
667
  maxDelayMs: Math.max(0, retryOptions.maxDelayMs),
668
+ shouldRetry: retryOptions.shouldRetry,
642
669
  }
643
670
  : undefined;
644
671
  let attempt = 0;
@@ -647,15 +674,69 @@ async function synthesizeWithRetry(
647
674
  try {
648
675
  return await synthesizeSsml(ssml, config);
649
676
  } catch (error) {
650
- if (!options || attempt >= options.maxRetries || !isRetryableSynthesisError(error)) throw error;
677
+ if (
678
+ !options ||
679
+ attempt >= options.maxRetries ||
680
+ !(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error))
681
+ )
682
+ throw error;
651
683
  attempt += 1;
652
- const delayMs = retryDelay(options, attempt);
684
+ const delayMs = retryDelay(options, attempt, error);
653
685
  onRetry(attempt, delayMs);
654
686
  await waitForRetry(delayMs, config.signal);
655
687
  }
656
688
  }
657
689
  }
658
690
 
691
+ interface AbortScope {
692
+ signal: AbortSignal;
693
+ timedOut: () => boolean;
694
+ dispose: () => void;
695
+ abort: () => void;
696
+ }
697
+
698
+ function createAbortScope(parent: AbortSignal | undefined, timeoutMs: number | undefined): AbortScope {
699
+ const controller = new AbortController();
700
+ let didTimeout = false;
701
+ const onAbort = () => controller.abort();
702
+ if (parent?.aborted) controller.abort();
703
+ parent?.addEventListener("abort", onAbort, { once: true });
704
+ const timer =
705
+ timeoutMs !== undefined && timeoutMs > 0
706
+ ? setTimeout(() => {
707
+ didTimeout = true;
708
+ controller.abort();
709
+ }, timeoutMs)
710
+ : undefined;
711
+ return {
712
+ signal: controller.signal,
713
+ timedOut: () => didTimeout,
714
+ dispose: () => {
715
+ if (timer) clearTimeout(timer);
716
+ parent?.removeEventListener("abort", onAbort);
717
+ },
718
+ abort: () => controller.abort(),
719
+ };
720
+ }
721
+
722
+ async function synthesizeChunkWithTimeout(
723
+ ssml: string,
724
+ config: TtsConfig,
725
+ retryOptions: RetryOptions | undefined,
726
+ timeoutMs: number | undefined,
727
+ onRetry: (retryAttempt: number, nextRetryDelayMs: number) => void,
728
+ ): Promise<SsmlSynthesisResult> {
729
+ const scope = createAbortScope(config.signal, timeoutMs);
730
+ try {
731
+ return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry);
732
+ } catch (error) {
733
+ if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
734
+ throw error;
735
+ } finally {
736
+ scope.dispose();
737
+ }
738
+ }
739
+
659
740
  /** Synthesizes chunks with bounded concurrency, retries transient failures, and merges in chunk order. */
660
741
  export async function synthesizeSsmlChunks(
661
742
  chunks: readonly (SsmlSynthesisChunk | string)[],
@@ -663,6 +744,16 @@ export async function synthesizeSsmlChunks(
663
744
  ): Promise<SsmlSynthesisResult> {
664
745
  const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
665
746
  const totalChunks = chunks.length;
747
+ const cachedChunks = new Map((config.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
748
+ for (const [index, cached] of cachedChunks) {
749
+ if (index >= 0 && index < totalChunks) results[index] = cached;
750
+ }
751
+ const requestedIndices = config.resumeChunkIndices
752
+ ? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks))
753
+ : undefined;
754
+ const shouldSynthesize = (index: number): boolean =>
755
+ !cachedChunks.has(index) && (requestedIndices === undefined || requestedIndices.has(index));
756
+ const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
666
757
  const report = (event: SynthesisProgressEvent): void => config.onProgress?.(event);
667
758
  for (const [index, chunk] of chunks.entries()) {
668
759
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
@@ -676,13 +767,17 @@ export async function synthesizeSsmlChunks(
676
767
  durationMs: 0,
677
768
  });
678
769
  }
679
- let completed = 0;
770
+ let completed = [...results].filter((result) => result !== undefined).length;
680
771
  let nextIndex = 0;
681
772
  const concurrency = resolveConcurrency(config.concurrency, chunks.length);
773
+ let firstError: unknown;
774
+ const failedIndices = new Set<number>();
682
775
  const worker = async (): Promise<void> => {
683
776
  while (true) {
684
777
  const index = nextIndex++;
685
778
  if (index >= chunks.length) return;
779
+ if (!shouldSynthesize(index)) continue;
780
+ if (firstError && config.cancelOnFailure !== false) return;
686
781
  const chunk = chunks[index];
687
782
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
688
783
  report({
@@ -696,10 +791,11 @@ export async function synthesizeSsmlChunks(
696
791
  });
697
792
  const startedAt = Date.now();
698
793
  try {
699
- const result = await synthesizeWithRetry(
794
+ const result = await synthesizeChunkWithTimeout(
700
795
  input.ssml,
701
796
  {
702
797
  ...config,
798
+ signal: scope.signal,
703
799
  ...(input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {}),
704
800
  ...((input.sourceNodePath ?? config.sourceNodePath)
705
801
  ? { sourceNodePath: [...(input.sourceNodePath ?? config.sourceNodePath ?? [])] }
@@ -710,6 +806,7 @@ export async function synthesizeSsmlChunks(
710
806
  onProgress: undefined,
711
807
  },
712
808
  config.retryOptions,
809
+ config.timeouts?.chunkWithRetriesMs ?? config.timeouts?.perChunkMs ?? config.timeoutMs,
713
810
  (retryAttempt, nextRetryDelayMs) =>
714
811
  report({
715
812
  currentChunk: completed,
@@ -736,6 +833,7 @@ export async function synthesizeSsmlChunks(
736
833
  durationMs: Date.now() - startedAt,
737
834
  });
738
835
  } catch (error) {
836
+ failedIndices.add(index);
739
837
  report({
740
838
  currentChunk: completed,
741
839
  totalChunks,
@@ -746,16 +844,38 @@ export async function synthesizeSsmlChunks(
746
844
  durationMs: Date.now() - startedAt,
747
845
  error,
748
846
  });
749
- throw error;
847
+ firstError ??= scope.timedOut()
848
+ ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`)
849
+ : error;
850
+ if (config.cancelOnFailure !== false) scope.abort();
851
+ return;
750
852
  }
751
853
  }
752
854
  };
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
- });
855
+ try {
856
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
857
+ if (firstError) throw firstError;
858
+ const orderedResults = results.filter((result): result is SsmlSynthesisResult => result !== undefined);
859
+ return await mergeSynthesisResults(orderedResults, {
860
+ format: (config.outputFormat ?? DEFAULT_OUTPUT_FORMAT) as AzureTtsOutputFormat,
861
+ signal: scope.signal,
862
+ customMerger: config.customMerger,
863
+ outputMimeType: config.outputMimeType,
864
+ postMergeValidator: config.postMergeValidator,
865
+ });
866
+ } catch (error) {
867
+ const partial = {
868
+ synthesizedChunks: results.flatMap((result, chunkIndex) => (result ? [{ ...result, chunkIndex }] : [])),
869
+ completedChunks: results.flatMap((result, chunkIndex) => (result ? [{ ...result, chunkIndex }] : [])),
870
+ pendingChunkIndices: chunks.flatMap((_chunk, chunkIndex) => (results[chunkIndex] ? [] : [chunkIndex])),
871
+ failedChunkIndices: [...failedIndices],
872
+ totalChunks,
873
+ };
874
+ if (error && typeof error === "object") (error as { partialResult?: unknown }).partialResult = partial;
875
+ throw error;
876
+ } finally {
877
+ scope.dispose();
878
+ }
759
879
  }
760
880
 
761
881
  /** Concatenates audio buffers and shifts all synchronization events by prior chunk durations. */
@@ -845,6 +965,10 @@ export function mergeSynthesisResults(
845
965
  results: readonly SsmlSynthesisResult[],
846
966
  options: AsyncMergeSynthesisOptions,
847
967
  ): Promise<MergedSynthesisResult>;
968
+ export function mergeSynthesisResults(
969
+ results: readonly SsmlSynthesisResult[],
970
+ options: MergeSynthesisOptions,
971
+ ): MergedSynthesisResult | Promise<MergedSynthesisResult>;
848
972
  export function mergeSynthesisResults(
849
973
  results: readonly SsmlSynthesisResult[],
850
974
  options: MergeAudioOptions,
@@ -880,13 +1004,24 @@ export function mergeSynthesisResults(
880
1004
  )
881
1005
  throw new MergeError("The custom audio merger returned an invalid audio buffer.");
882
1006
  if (signal.aborted) throw new SynthesisCancelledError();
883
- return createMergedResult(
1007
+ const result = createMergedResult(
884
1008
  results,
885
1009
  merged,
886
1010
  format,
887
1011
  inspectAudioSpecification(merged, format),
888
1012
  resolvedOptions.outputMimeType,
889
1013
  );
1014
+ return Promise.resolve(
1015
+ resolvedOptions.postMergeValidator?.(result, {
1016
+ format,
1017
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
1018
+ inputSpecs,
1019
+ signal,
1020
+ }),
1021
+ ).then((valid) => {
1022
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1023
+ return result;
1024
+ });
890
1025
  })
891
1026
  .catch((error: unknown) => {
892
1027
  if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
@@ -895,13 +1030,28 @@ export function mergeSynthesisResults(
895
1030
  });
896
1031
  }
897
1032
  try {
898
- return createMergedResult(
1033
+ const result = createMergedResult(
899
1034
  results,
900
1035
  mergeAudioBuffers(buffers, { format }),
901
1036
  format,
902
1037
  inputSpecs[0],
903
1038
  resolvedOptions.outputMimeType,
904
1039
  );
1040
+ if (resolvedOptions.postMergeValidator) {
1041
+ const validation = resolvedOptions.postMergeValidator(result, {
1042
+ format,
1043
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
1044
+ inputSpecs,
1045
+ signal,
1046
+ });
1047
+ if (validation instanceof Promise)
1048
+ return validation.then((valid) => {
1049
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1050
+ return result;
1051
+ });
1052
+ if (validation === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1053
+ }
1054
+ return result;
905
1055
  } catch (error) {
906
1056
  if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
907
1057
  throw error;
package/src/types.ts CHANGED
@@ -4,6 +4,7 @@ import type { AzureTtsOutputFormat } from "./outputFormats.ts";
4
4
  export interface TtsConfig {
5
5
  signal?: AbortSignal;
6
6
  timeoutMs?: number;
7
+ timeouts?: SynthesisTimeouts;
7
8
  endpoint?: string;
8
9
  subscriptionKey: string;
9
10
  region: string;
@@ -20,6 +21,12 @@ export interface TtsConfig {
20
21
  sourceMarkers?: SsmlSourceMarker[];
21
22
  concurrency?: number;
22
23
  retryOptions?: RetryOptions;
24
+ cancelOnFailure?: boolean;
25
+ resumeChunks?: readonly SynthesizedChunk[];
26
+ resumeChunkIndices?: readonly number[];
27
+ customMerger?: CustomAudioMerger;
28
+ outputMimeType?: string;
29
+ postMergeValidator?: PostMergeValidator;
23
30
  }
24
31
 
25
32
  export type MappingStatus = "exact" | "fallback" | "unmapped";
@@ -31,13 +38,24 @@ export interface AudioSpecification {
31
38
  sampleRate: number;
32
39
  channels: number;
33
40
  bitrate?: number;
41
+ bitDepth?: number;
42
+ container?: string;
43
+ isVbr?: boolean;
34
44
  isCompressed: boolean;
35
45
  }
36
46
 
47
+ export interface SynthesisTimeouts {
48
+ urlValidationMs?: number;
49
+ perChunkMs?: number;
50
+ chunkWithRetriesMs?: number;
51
+ totalJobMs?: number;
52
+ }
53
+
37
54
  export interface RetryOptions {
38
55
  maxRetries: number;
39
56
  initialDelayMs: number;
40
57
  maxDelayMs: number;
58
+ shouldRetry?: (error: unknown, attempt: number) => boolean;
41
59
  }
42
60
 
43
61
  export type SynthesisChunkStatus = "pending" | "synthesizing" | "success" | "failed";
@@ -120,11 +138,50 @@ export interface SynthesizeChunksOptions {
120
138
  outputFormat?: AzureTtsOutputFormat | string;
121
139
  signal?: AbortSignal;
122
140
  timeoutMs?: number;
141
+ timeouts?: SynthesisTimeouts;
123
142
  sourceNodePath?: string[];
124
143
  concurrency?: number;
125
144
  retryOptions?: RetryOptions;
145
+ cancelOnFailure?: boolean;
146
+ resumeChunks?: readonly SynthesizedChunk[];
147
+ resumeChunkIndices?: readonly number[];
148
+ customMerger?: CustomAudioMerger;
149
+ outputMimeType?: string;
150
+ postMergeValidator?: PostMergeValidator;
151
+ }
152
+
153
+ export interface CustomMergerContext {
154
+ format: string;
155
+ outputMimeType: string;
156
+ inputSpecs: readonly AudioSpecification[];
157
+ signal: AbortSignal;
158
+ }
159
+
160
+ export type CustomAudioMerger = (
161
+ buffers: ArrayBuffer[],
162
+ context: CustomMergerContext,
163
+ ) => Promise<ArrayBuffer> | ArrayBuffer;
164
+
165
+ export type PostMergeValidator = (
166
+ result: MergedSynthesisResult,
167
+ context: CustomMergerContext,
168
+ ) => boolean | undefined | Promise<boolean | undefined>;
169
+
170
+ export interface SynthesizedChunk extends SsmlSynthesisResult {
171
+ chunkIndex: number;
126
172
  }
127
173
 
174
+ export interface PartialChunkSynthesisResult {
175
+ synthesizedChunks: readonly SynthesizedChunk[];
176
+ completedChunks: readonly SynthesizedChunk[];
177
+ pendingChunkIndices: readonly number[];
178
+ failedChunkIndices: readonly number[];
179
+ totalChunks: number;
180
+ }
181
+
182
+ /** Alias for applications that use the shorter result name. */
183
+ export type PartialSynthesisResult = PartialChunkSynthesisResult;
184
+
128
185
  export interface SynthesisProgressEvent {
129
186
  /** 1-based completed chunk count retained for backward compatibility. */
130
187
  currentChunk: number;
@@ -150,6 +207,7 @@ export interface AzureTtsLogger {
150
207
  export interface AzureTtsClientOptions {
151
208
  signal?: AbortSignal;
152
209
  timeoutMs?: number;
210
+ timeouts?: SynthesisTimeouts;
153
211
  subscriptionKey: string;
154
212
  region: string;
155
213
  endpoint?: string;
@@ -22,6 +22,8 @@ export interface FetchedAzureVoiceCatalogMetadata {
22
22
  generatedAt: string;
23
23
  apiVersion: string;
24
24
  regions: readonly string[];
25
+ expiresAt?: string;
26
+ regionDiffs?: Readonly<Record<string, readonly string[]>>;
25
27
  }
26
28
 
27
29
  export interface AzureVoiceCatalog {
@@ -130,6 +132,8 @@ export async function fetchAzureVoiceCatalog(options: FetchAzureVoiceCatalogOpti
130
132
  generatedAt: new Date().toISOString(),
131
133
  apiVersion: AZURE_VOICE_API_VERSION,
132
134
  regions,
135
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
136
+ regionDiffs: {},
133
137
  },
134
138
  };
135
139
  }
@@ -0,0 +1,125 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ AzureTtsError,
5
+ BatchChunkValidationError,
6
+ getRetryAfterDelayMs,
7
+ synthesizeSsmlChunksSafe,
8
+ } from "../src/index.ts";
9
+
10
+ const validSsml = (text: string) =>
11
+ `<speak version="1.0" xml:lang="en-US"><voice name="en-US-JennyNeural">${text}</voice></speak>`;
12
+
13
+ test("aggregates every invalid chunk and every diagnostic before synthesis", async () => {
14
+ let calls = 0;
15
+ const result = await synthesizeSsmlChunksSafe(
16
+ {
17
+ synthesizeSsml: async () => {
18
+ calls += 1;
19
+ return { audioData: new ArrayBuffer(0), durationMs: 0 };
20
+ },
21
+ },
22
+ ["<speak>", "<speak>", validSsml("ok")],
23
+ );
24
+
25
+ assert.equal(result.ok, false);
26
+ assert.equal(calls, 0);
27
+ if (!result.ok) {
28
+ assert.ok(result.error instanceof BatchChunkValidationError);
29
+ assert.deepEqual(
30
+ result.error.chunkDiagnostics.map(({ chunkIndex }) => chunkIndex),
31
+ [0, 1],
32
+ );
33
+ assert.equal(result.error.totalErrorCount, 2);
34
+ }
35
+ });
36
+
37
+ test("custom merger and post-merge validation are part of safe chunk synthesis", async () => {
38
+ let validated = false;
39
+ const result = await synthesizeSsmlChunksSafe(
40
+ {
41
+ synthesizeSsml: async (ssml) => ({
42
+ audioData: Uint8Array.of(ssml.includes("one") ? 1 : 2).buffer,
43
+ durationMs: 10,
44
+ }),
45
+ },
46
+ [validSsml("one"), validSsml("two")],
47
+ {
48
+ concurrency: 2,
49
+ outputMimeType: "audio/custom",
50
+ customMerger: (buffers) => {
51
+ const output = new Uint8Array(buffers.reduce((total, buffer) => total + buffer.byteLength, 0));
52
+ let offset = 0;
53
+ for (const buffer of buffers) {
54
+ output.set(new Uint8Array(buffer), offset);
55
+ offset += buffer.byteLength;
56
+ }
57
+ return output.buffer;
58
+ },
59
+ postMergeValidator: (merged) => {
60
+ validated = merged.mimeType === "audio/custom";
61
+ },
62
+ },
63
+ );
64
+
65
+ assert.equal(result.ok, true);
66
+ assert.equal(validated, true);
67
+ if (result.ok) assert.equal(result.value.mimeType, "audio/custom");
68
+ });
69
+
70
+ test("cancels remaining work and resumes from the partial chunk cache", async () => {
71
+ let calls = 0;
72
+ let failFirstAttempt = true;
73
+ const client = {
74
+ synthesizeSsml: async (ssml: string, options?: { signal?: AbortSignal }) => {
75
+ calls += 1;
76
+ if (ssml.includes("fail") && failFirstAttempt) {
77
+ failFirstAttempt = false;
78
+ throw new AzureTtsError(503, "Unavailable", "", null);
79
+ }
80
+ await new Promise<void>((resolve, reject) => {
81
+ const timer = setTimeout(resolve, ssml.includes("one") ? 1 : 50);
82
+ options?.signal?.addEventListener(
83
+ "abort",
84
+ () => {
85
+ clearTimeout(timer);
86
+ reject(new Error("aborted"));
87
+ },
88
+ { once: true },
89
+ );
90
+ });
91
+ return { audioData: Uint8Array.of(calls).buffer, durationMs: 10 };
92
+ },
93
+ };
94
+ const chunks = [validSsml("one"), validSsml("fail"), validSsml("three")];
95
+ const first = await synthesizeSsmlChunksSafe(client, chunks, { concurrency: 2 });
96
+ assert.equal(first.ok, false);
97
+ assert.ok(first.partialResult);
98
+ if (!first.ok && first.partialResult) {
99
+ const resumed = await synthesizeSsmlChunksSafe(client, chunks, {
100
+ concurrency: 2,
101
+ resumeChunks: first.partialResult.synthesizedChunks,
102
+ resumeChunkIndices: first.partialResult.pendingChunkIndices,
103
+ });
104
+ assert.equal(resumed.ok, true);
105
+ }
106
+ assert.ok(calls < 6);
107
+ });
108
+
109
+ test("prioritizes Retry-After and supports structured per-chunk timeouts", async () => {
110
+ const retryAfter = new AzureTtsError(429, "Too Many Requests", "", null, { "retry-after": "2" });
111
+ assert.equal(getRetryAfterDelayMs(retryAfter), 2_000);
112
+
113
+ const result = await synthesizeSsmlChunksSafe(
114
+ {
115
+ synthesizeSsml: async (_ssml, options) =>
116
+ new Promise((_resolve, reject) =>
117
+ options?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }),
118
+ ),
119
+ },
120
+ [validSsml("slow")],
121
+ { timeouts: { perChunkMs: 5 } },
122
+ );
123
+ assert.equal(result.ok, false);
124
+ if (!result.ok) assert.equal(result.error.kind, "timeout");
125
+ });