@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssml-builder-js/azure-tts-client",
3
- "version": "2.16.0",
3
+ "version": "2.17.0",
4
4
  "description": "Azure Text-to-Speech client using the Microsoft Speech SDK",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,7 +35,7 @@
35
35
  "typescript": "^6.0.3"
36
36
  },
37
37
  "dependencies": {
38
- "@ssml-builder-js/ssml-core": "^2.16.0",
38
+ "@ssml-builder-js/ssml-core": "^2.17.0",
39
39
  "microsoft-cognitiveservices-speech-sdk": "1.51.0"
40
40
  }
41
41
  }
package/src/client.ts CHANGED
@@ -19,16 +19,16 @@ export class AzureTtsClient {
19
19
  }
20
20
 
21
21
  async synthesize(ssml: string): Promise<ArrayBuffer> {
22
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = this.#options;
22
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = this.#options;
23
23
  const endpoint = this.#options.endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
24
24
  this.#options.logger?.debug?.("Using Azure TTS endpoint:", endpoint);
25
25
 
26
- const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs };
26
+ const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts };
27
27
  return synthesizeSpeech(ssml, config);
28
28
  }
29
29
 
30
30
  async synthesizeSsml(ssml: string, options: Partial<TtsConfig> = {}): Promise<SsmlSynthesisResult> {
31
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = this.#options;
31
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = this.#options;
32
32
  const endpoint = this.#options.endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
33
33
  this.#options.logger?.debug?.("Using Azure TTS endpoint:", endpoint);
34
34
 
@@ -39,6 +39,7 @@ export class AzureTtsClient {
39
39
  outputFormat: options.outputFormat ?? outputFormat,
40
40
  signal: options.signal ?? signal,
41
41
  timeoutMs: options.timeoutMs ?? timeoutMs,
42
+ timeouts: options.timeouts ?? timeouts,
42
43
  sourceNodePath: options.sourceNodePath,
43
44
  sourceTextSegments: options.sourceTextSegments,
44
45
  sourceMarkers: options.sourceMarkers,
@@ -49,7 +50,7 @@ export class AzureTtsClient {
49
50
  chunks: readonly (SsmlSynthesisChunk | string)[],
50
51
  options: SynthesizeChunksOptions = {},
51
52
  ): Promise<SsmlSynthesisResult> {
52
- const { region, subscriptionKey, outputFormat, signal, timeoutMs } = this.#options;
53
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = this.#options;
53
54
  const endpoint = this.#options.endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
54
55
  return synthesizeSsmlChunks(chunks, {
55
56
  endpoint,
@@ -58,10 +59,17 @@ export class AzureTtsClient {
58
59
  outputFormat: options.outputFormat ?? outputFormat,
59
60
  signal: options.signal ?? signal,
60
61
  timeoutMs: options.timeoutMs ?? timeoutMs,
62
+ timeouts: options.timeouts ?? timeouts,
61
63
  sourceNodePath: options.sourceNodePath,
62
64
  onProgress: options.onProgress ?? this.#options.onProgress,
63
65
  concurrency: options.concurrency ?? this.#options.concurrency,
64
66
  retryOptions: options.retryOptions ?? this.#options.retryOptions,
67
+ cancelOnFailure: options.cancelOnFailure,
68
+ resumeChunks: options.resumeChunks,
69
+ resumeChunkIndices: options.resumeChunkIndices,
70
+ customMerger: options.customMerger,
71
+ outputMimeType: options.outputMimeType,
72
+ postMergeValidator: options.postMergeValidator,
65
73
  });
66
74
  }
67
75
 
@@ -78,6 +86,7 @@ export class AzureTtsClient {
78
86
  outputFormat: options.outputFormat ?? this.#options.outputFormat,
79
87
  signal: options.signal ?? this.#options.signal,
80
88
  timeoutMs: options.timeoutMs ?? this.#options.timeoutMs,
89
+ timeouts: options.timeouts ?? this.#options.timeouts,
81
90
  onProgress: options.onProgress ?? this.#options.onProgress,
82
91
  concurrency: options.concurrency ?? this.#options.concurrency,
83
92
  retryOptions: options.retryOptions ?? this.#options.retryOptions,
package/src/errors.ts CHANGED
@@ -13,15 +13,57 @@ export class AzureTtsError extends Error {
13
13
  readonly statusText: string;
14
14
  readonly responseBody: string;
15
15
  readonly requestId: string | null;
16
-
17
- constructor(status: number, statusText: string, responseBody: string, requestId: string | null) {
16
+ readonly retryAfterMs?: number;
17
+
18
+ constructor(
19
+ status: number,
20
+ statusText: string,
21
+ responseBody: string,
22
+ requestId: string | null,
23
+ responseHeaders?: Headers | Readonly<Record<string, string>>,
24
+ ) {
18
25
  super(`Azure TTS request failed: ${status} ${statusText}`);
19
26
  this.name = "AzureTtsError";
20
27
  this.status = status;
21
28
  this.statusText = statusText;
22
29
  this.responseBody = responseBody;
23
30
  this.requestId = requestId;
31
+ const value =
32
+ responseHeaders instanceof Headers
33
+ ? responseHeaders.get("retry-after")
34
+ : (responseHeaders?.["retry-after"] ?? responseHeaders?.["Retry-After"]);
35
+ const seconds = value ? Number(value.trim()) : NaN;
36
+ const date = value ? Date.parse(value) : NaN;
37
+ if (Number.isFinite(seconds) && seconds >= 0) this.retryAfterMs = seconds * 1000;
38
+ else if (Number.isFinite(date)) this.retryAfterMs = Math.max(0, date - Date.now());
39
+ }
40
+ }
41
+
42
+ /** Reads Retry-After from an error-like value, returning milliseconds when present. */
43
+ export function getRetryAfterDelayMs(error: unknown): number | undefined {
44
+ if (error instanceof AzureTtsError && error.retryAfterMs !== undefined) return error.retryAfterMs;
45
+ if (!error || typeof error !== "object") return undefined;
46
+ const candidate = error as { retryAfterMs?: unknown; headers?: unknown; response?: unknown };
47
+ if (typeof candidate.retryAfterMs === "number" && candidate.retryAfterMs >= 0) return candidate.retryAfterMs;
48
+ const headers = candidate.headers ?? (candidate.response as { headers?: unknown } | undefined)?.headers;
49
+ if (headers instanceof Headers) {
50
+ const value = headers.get("retry-after");
51
+ if (!value) return undefined;
52
+ const seconds = Number(value.trim());
53
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
54
+ const date = Date.parse(value);
55
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
56
+ }
57
+ if (headers && typeof headers === "object") {
58
+ const value =
59
+ (headers as Record<string, unknown>)["retry-after"] ?? (headers as Record<string, unknown>)["Retry-After"];
60
+ if (typeof value !== "string") return undefined;
61
+ const seconds = Number(value.trim());
62
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
63
+ const date = Date.parse(value);
64
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
24
65
  }
66
+ return undefined;
25
67
  }
26
68
 
27
69
  export class AzureTtsSdkError extends AzureTtsError {
package/src/index.ts CHANGED
@@ -17,6 +17,13 @@ export type {
17
17
  SynthesizeChunksOptions,
18
18
  SynthesisChunkStatus,
19
19
  RetryOptions,
20
+ SynthesisTimeouts,
21
+ SynthesizedChunk,
22
+ PartialChunkSynthesisResult,
23
+ PartialSynthesisResult,
24
+ CustomMergerContext,
25
+ CustomAudioMerger,
26
+ PostMergeValidator,
20
27
  TtsConfig,
21
28
  } from "./types.ts";
22
29
  export {
@@ -27,6 +34,7 @@ export {
27
34
  SynthesisCancelledError,
28
35
  SynthesisTimeoutError,
29
36
  UnsupportedMergeFormatError,
37
+ getRetryAfterDelayMs,
30
38
  } from "./errors.ts";
31
39
  export type { AzureTtsSynthesisError, SynthesisErrorKind } from "./errors.ts";
32
40
  export { AzureTtsClient } from "./client.ts";
@@ -41,7 +49,6 @@ export {
41
49
  synthesizeSsmlChunks,
42
50
  } from "./synthesis.ts";
43
51
  export type {
44
- CustomMergerContext,
45
52
  InputAudioSpecs,
46
53
  MergeAudioFormat,
47
54
  MergeAudioOptions,
@@ -49,7 +56,12 @@ export type {
49
56
  } from "./synthesis.ts";
50
57
  export { DEFAULT_OUTPUT_FORMAT, resolveMimeType } from "./outputFormats.ts";
51
58
  export type { AzureTtsOutputFormat } from "./outputFormats.ts";
52
- export { ChunkValidationError, synthesizeSsmlChunksSafe, synthesizeSsmlSafe } from "./safe.ts";
59
+ export {
60
+ BatchChunkValidationError,
61
+ ChunkValidationError,
62
+ synthesizeSsmlChunksSafe,
63
+ synthesizeSsmlSafe,
64
+ } from "./safe.ts";
53
65
  export type {
54
66
  AzureApiErrorResult,
55
67
  Result,
@@ -62,6 +74,7 @@ export type {
62
74
  SsmlSynthesisChunksSafeResult,
63
75
  SynthesizeSsmlChunksSafeOptions,
64
76
  ValidationErrorResult,
77
+ ChunkDiagnostics,
65
78
  } from "./safe.ts";
66
79
  export { fetchAzureVoiceCatalog } from "./voiceCatalog.ts";
67
80
  export type {
package/src/safe.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  type AzureTtsSynthesisError,
11
11
  type SynthesisErrorKind,
12
12
  toSynthesisError,
13
+ getRetryAfterDelayMs,
13
14
  } from "./errors.ts";
14
15
  import type { AzureTtsClient } from "./client.ts";
15
16
  import { mergeSynthesisResults } from "./synthesis.ts";
@@ -19,6 +20,11 @@ import type {
19
20
  SynthesisProgressEvent,
20
21
  SynthesizeChunksOptions,
21
22
  RetryOptions,
23
+ SynthesisTimeouts,
24
+ SynthesizedChunk,
25
+ PartialChunkSynthesisResult,
26
+ CustomAudioMerger,
27
+ PostMergeValidator,
22
28
  } from "./types.ts";
23
29
  import type { AzureTtsOutputFormat } from "./outputFormats.ts";
24
30
 
@@ -43,6 +49,29 @@ export class ChunkValidationError extends Error {
43
49
  }
44
50
  }
45
51
 
52
+ export interface ChunkDiagnostics {
53
+ readonly chunkIndex: number;
54
+ readonly diagnostics: readonly SsmlDiagnostic[];
55
+ }
56
+
57
+ export class BatchChunkValidationError extends ChunkValidationError {
58
+ readonly chunkDiagnostics: readonly ChunkDiagnostics[];
59
+ readonly totalErrorCount: number;
60
+ readonly errorCount: number;
61
+ readonly totalErrors: number;
62
+
63
+ constructor(chunkDiagnostics: readonly ChunkDiagnostics[]) {
64
+ const first = chunkDiagnostics[0];
65
+ super(first?.chunkIndex ?? -1, first?.diagnostics ?? []);
66
+ this.name = "BatchChunkValidationError";
67
+ this.message = `SSML validation failed for ${chunkDiagnostics.length} chunk(s); the Azure Speech API was not called.`;
68
+ this.chunkDiagnostics = chunkDiagnostics;
69
+ this.totalErrorCount = chunkDiagnostics.reduce((total, chunk) => total + chunk.diagnostics.length, 0);
70
+ this.errorCount = this.totalErrorCount;
71
+ this.totalErrors = this.totalErrorCount;
72
+ }
73
+ }
74
+
46
75
  export type Result<T, E> =
47
76
  | { readonly ok: true; readonly success: true; readonly status: "success"; readonly value: T }
48
77
  | (E extends { readonly kind: infer Kind extends SynthesisErrorKind }
@@ -51,18 +80,29 @@ export type Result<T, E> =
51
80
  readonly success: false;
52
81
  readonly status: Kind;
53
82
  readonly error: E;
83
+ readonly partialResult?: PartialChunkSynthesisResult;
54
84
  }
55
85
  : {
56
86
  readonly ok: false;
57
87
  readonly success: false;
58
88
  readonly status: SynthesisErrorKind;
59
89
  readonly error: E;
90
+ readonly partialResult?: PartialChunkSynthesisResult;
60
91
  });
61
92
 
62
93
  export type SynthesisResult<T, E> = Result<T, E>;
63
94
 
64
- function failure<E extends { readonly kind: SynthesisErrorKind }>(error: E): Result<never, E> {
65
- return { ok: false, success: false, status: error.kind, error } as Result<never, E>;
95
+ function failure<E extends { readonly kind: SynthesisErrorKind }>(
96
+ error: E,
97
+ partialResult?: PartialChunkSynthesisResult,
98
+ ): Result<never, E> {
99
+ return {
100
+ ok: false,
101
+ success: false,
102
+ status: error.kind,
103
+ error,
104
+ ...(partialResult ? { partialResult } : {}),
105
+ } as Result<never, E>;
66
106
  }
67
107
 
68
108
  export type Success<T> = Extract<Result<T, never>, { readonly ok: true }>;
@@ -81,6 +121,7 @@ export interface SynthesizeSsmlSafeOptions extends AzureValidationOptions {
81
121
  /** Optional nested form for callers that want to keep validation settings grouped. */
82
122
  validation?: AzureValidationOptions;
83
123
  signal?: AbortSignal;
124
+ timeouts?: SynthesisTimeouts;
84
125
  }
85
126
 
86
127
  export interface SynthesizeSsmlChunksSafeOptions extends AzureValidationOptions {
@@ -88,15 +129,23 @@ export interface SynthesizeSsmlChunksSafeOptions extends AzureValidationOptions
88
129
  outputFormat?: string;
89
130
  signal?: AbortSignal;
90
131
  timeoutMs?: number;
132
+ timeouts?: SynthesisTimeouts;
91
133
  sourceNodePath?: string[];
92
134
  onProgress?: (event: SynthesisProgressEvent) => void;
93
135
  concurrency?: number;
94
136
  retryOptions?: RetryOptions;
137
+ cancelOnFailure?: boolean;
138
+ resumeChunks?: readonly SynthesizedChunk[];
139
+ resumeChunkIndices?: readonly number[];
140
+ customMerger?: CustomAudioMerger;
141
+ outputMimeType?: string;
142
+ postMergeValidator?: PostMergeValidator;
95
143
  }
96
144
 
97
145
  export type SsmlSynthesisChunksSafeResult =
98
146
  | Result<SsmlSynthesisResult, never>
99
147
  | Result<never, ChunkValidationError>
148
+ | Result<never, BatchChunkValidationError>
100
149
  | Result<never, SsmlSynthesisError | ChunkValidationError>;
101
150
 
102
151
  interface SynthesisClient {
@@ -107,6 +156,44 @@ interface SynthesisClient {
107
156
  ): Promise<SsmlSynthesisResult>;
108
157
  }
109
158
 
159
+ function partialResultFrom(error: unknown): PartialChunkSynthesisResult | undefined {
160
+ if (!error || typeof error !== "object") return undefined;
161
+ const partial = (error as { partialResult?: unknown }).partialResult;
162
+ if (!partial || typeof partial !== "object") return undefined;
163
+ return partial as PartialChunkSynthesisResult;
164
+ }
165
+
166
+ interface SafeAbortScope {
167
+ signal: AbortSignal;
168
+ timedOut: () => boolean;
169
+ dispose: () => void;
170
+ abort: () => void;
171
+ }
172
+
173
+ function createSafeAbortScope(parent: AbortSignal | undefined, timeoutMs: number | undefined): SafeAbortScope {
174
+ const controller = new AbortController();
175
+ let didTimeout = false;
176
+ const onAbort = () => controller.abort();
177
+ if (parent?.aborted) controller.abort();
178
+ parent?.addEventListener("abort", onAbort, { once: true });
179
+ const timer =
180
+ timeoutMs !== undefined && timeoutMs > 0
181
+ ? setTimeout(() => {
182
+ didTimeout = true;
183
+ controller.abort();
184
+ }, timeoutMs)
185
+ : undefined;
186
+ return {
187
+ signal: controller.signal,
188
+ timedOut: () => didTimeout,
189
+ dispose: () => {
190
+ if (timer) clearTimeout(timer);
191
+ parent?.removeEventListener("abort", onAbort);
192
+ },
193
+ abort: () => controller.abort(),
194
+ };
195
+ }
196
+
110
197
  function isRetryable(error: unknown): boolean {
111
198
  if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
112
199
  const status =
@@ -123,6 +210,10 @@ function delayForRetry(options: RetryOptions, attempt: number): number {
123
210
  return Math.floor(Math.random() * (base + 1));
124
211
  }
125
212
 
213
+ function retryDelayForError(options: RetryOptions, attempt: number, error: unknown): number {
214
+ return getRetryAfterDelayMs(error) ?? delayForRetry(options, attempt);
215
+ }
216
+
126
217
  function resolveConcurrency(value: number | undefined, total: number): number {
127
218
  if (value === undefined) return 1;
128
219
  if (value === Infinity) return Math.max(1, total);
@@ -140,6 +231,7 @@ async function retryableSynthesis(
140
231
  maxRetries: Math.max(0, Math.floor(options.maxRetries)),
141
232
  initialDelayMs: options.initialDelayMs,
142
233
  maxDelayMs: options.maxDelayMs,
234
+ shouldRetry: options.shouldRetry,
143
235
  }
144
236
  : undefined;
145
237
  let attempt = 0;
@@ -148,9 +240,10 @@ async function retryableSynthesis(
148
240
  try {
149
241
  return await synthesize();
150
242
  } catch (error) {
151
- if (!retry || attempt >= retry.maxRetries || !isRetryable(error)) throw error;
243
+ if (!retry || attempt >= retry.maxRetries || !(retry.shouldRetry?.(error, attempt + 1) ?? isRetryable(error)))
244
+ throw error;
152
245
  attempt += 1;
153
- const delayMs = delayForRetry(retry, attempt);
246
+ const delayMs = retryDelayForError(retry, attempt, error);
154
247
  onRetry(attempt, delayMs);
155
248
  if (delayMs > 0)
156
249
  await new Promise<void>((resolve, reject) => {
@@ -169,13 +262,20 @@ async function retryableSynthesis(
169
262
  }
170
263
  }
171
264
 
172
- function sharedValidationOptions(options: AzureValidationOptions, signal?: AbortSignal): AzureValidationOptions {
265
+ function sharedValidationOptions(
266
+ options: AzureValidationOptions & { timeouts?: SynthesisTimeouts },
267
+ signal?: AbortSignal,
268
+ ): AzureValidationOptions {
173
269
  const validator = options.urlValidator ?? options.customUrlValidator;
174
270
  if (!validator) return signal ? withValidationSignal(options, signal) : options;
175
271
  const runner = createAzureUrlValidatorRunner(validator as AzureUrlValidator, {
176
272
  ...(options.urlValidation ?? {}),
177
273
  ...(options.urlValidatorConcurrency !== undefined ? { concurrency: options.urlValidatorConcurrency } : {}),
178
- ...(options.urlValidatorTimeoutMs !== undefined ? { timeoutMs: options.urlValidatorTimeoutMs } : {}),
274
+ ...(options.timeouts?.urlValidationMs !== undefined
275
+ ? { timeoutMs: options.timeouts.urlValidationMs }
276
+ : options.urlValidatorTimeoutMs !== undefined
277
+ ? { timeoutMs: options.urlValidatorTimeoutMs }
278
+ : {}),
179
279
  ...(signal ? { signal } : {}),
180
280
  ...(options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}),
181
281
  });
@@ -211,7 +311,11 @@ export async function synthesizeSsmlSafe(
211
311
  ok: true,
212
312
  success: true,
213
313
  status: "success",
214
- value: await client.synthesizeSsml(ssml, { signal: options.signal }),
314
+ value: await client.synthesizeSsml(ssml, {
315
+ signal: options.signal,
316
+ timeoutMs: options.timeouts?.perChunkMs,
317
+ timeouts: options.timeouts,
318
+ }),
215
319
  };
216
320
  } catch (error) {
217
321
  const synthesisError = toSynthesisError(error);
@@ -225,7 +329,10 @@ export async function synthesizeSsmlChunksSafe(
225
329
  chunks: readonly (SsmlSynthesisChunk | string)[],
226
330
  options: SynthesizeSsmlChunksSafeOptions = {},
227
331
  ): Promise<SsmlSynthesisChunksSafeResult> {
228
- const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
332
+ const validationOptions = sharedValidationOptions(
333
+ { ...(options.validation ?? options), timeouts: options.timeouts },
334
+ options.signal,
335
+ );
229
336
  if (options.signal?.aborted) {
230
337
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
231
338
  return failure(error);
@@ -261,17 +368,20 @@ export async function synthesizeSsmlChunksSafe(
261
368
  return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
262
369
  }),
263
370
  );
264
- const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
265
371
  if (options.signal?.aborted) {
266
372
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
267
373
  return failure(error);
268
374
  }
269
- if (firstInvalidIndex >= 0) {
270
- const error = new ChunkValidationError(firstInvalidIndex, validations[firstInvalidIndex] ?? []);
271
- pending(firstInvalidIndex, "failed", error);
375
+ const chunkDiagnostics = validations
376
+ .map((diagnostics, chunkIndex) => ({ chunkIndex, diagnostics }))
377
+ .filter((entry) => entry.diagnostics.length > 0);
378
+ if (chunkDiagnostics.length > 0) {
379
+ const error = new BatchChunkValidationError(chunkDiagnostics);
380
+ for (const entry of chunkDiagnostics) pending(entry.chunkIndex, "failed", error);
272
381
  return failure(error);
273
382
  }
274
383
 
384
+ let fallbackJobScope: SafeAbortScope | undefined;
275
385
  try {
276
386
  if (client.synthesizeChunks) {
277
387
  const normalizedChunks = chunks.map((chunk) => {
@@ -283,20 +393,45 @@ export async function synthesizeSsmlChunksSafe(
283
393
  outputFormat: options.outputFormat,
284
394
  signal: options.signal,
285
395
  timeoutMs: options.timeoutMs,
396
+ timeouts: options.timeouts,
286
397
  sourceNodePath: options.sourceNodePath,
287
398
  concurrency: options.concurrency,
288
399
  retryOptions: options.retryOptions,
400
+ cancelOnFailure: options.cancelOnFailure,
401
+ resumeChunks: options.resumeChunks,
402
+ resumeChunkIndices: options.resumeChunkIndices,
403
+ customMerger: options.customMerger,
404
+ outputMimeType: options.outputMimeType,
405
+ postMergeValidator: options.postMergeValidator,
289
406
  });
290
407
  return { ok: true, success: true, status: "success", value };
291
408
  }
292
409
  const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
293
- let completed = 0;
410
+ const cachedChunks = new Map((options.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
411
+ for (const [index, cached] of cachedChunks) {
412
+ if (index >= 0 && index < chunks.length) results[index] = cached;
413
+ }
414
+ const requestedIndices = options.resumeChunkIndices
415
+ ? new Set(options.resumeChunkIndices.filter((index) => index >= 0 && index < chunks.length))
416
+ : undefined;
417
+ const shouldSynthesize = (index: number): boolean =>
418
+ !cachedChunks.has(index) && (requestedIndices === undefined || requestedIndices.has(index));
419
+ const jobScope =
420
+ chunks.length > 1 || options.timeouts?.totalJobMs !== undefined
421
+ ? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs)
422
+ : undefined;
423
+ fallbackJobScope = jobScope;
424
+ const failedIndices = new Set<number>();
425
+ let firstError: unknown;
426
+ let completed = [...results].filter((result) => result !== undefined).length;
294
427
  let nextIndex = 0;
295
428
  const concurrency = resolveConcurrency(options.concurrency, chunks.length);
296
429
  const worker = async (): Promise<void> => {
297
430
  while (true) {
298
431
  const index = nextIndex++;
299
432
  if (index >= chunks.length) return;
433
+ if (!shouldSynthesize(index)) continue;
434
+ if (failedIndices.size > 0 && options.cancelOnFailure !== false) return;
300
435
  const chunk = chunks[index];
301
436
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
302
437
  const sourceNodePath = input.sourceNodePath;
@@ -304,30 +439,45 @@ export async function synthesizeSsmlChunksSafe(
304
439
  pending(index, "synthesizing");
305
440
  const startedAt = Date.now();
306
441
  try {
307
- const result = await retryableSynthesis(
308
- () =>
309
- client.synthesizeSsml(input.ssml, {
310
- outputFormat: options.outputFormat,
311
- signal: options.signal,
312
- timeoutMs: options.timeoutMs,
313
- sourceNodePath: input.sourceNodePath ?? options.sourceNodePath,
314
- }),
315
- options.retryOptions,
316
- options.signal,
317
- (retryAttempt, nextRetryDelayMs) =>
318
- options.onProgress?.({
319
- currentChunk: completed,
320
- totalChunks: chunks.length,
321
- percent: chunks.length === 0 ? 100 : Math.round((completed / chunks.length) * 100),
322
- chunkIndex: index,
323
- originalTextRange: input.originalTextRange,
324
- status: "synthesizing",
325
- durationMs: Date.now() - startedAt,
326
- retryAttempt,
327
- nextRetryDelayMs,
328
- isRetrying: true,
329
- }),
330
- );
442
+ const chunkTimeout = options.timeouts?.chunkWithRetriesMs ?? options.timeouts?.perChunkMs;
443
+ const chunkScope =
444
+ chunkTimeout !== undefined || jobScope
445
+ ? createSafeAbortScope(jobScope?.signal ?? options.signal, chunkTimeout ?? options.timeoutMs)
446
+ : undefined;
447
+ const chunkSignal = chunkScope?.signal ?? options.signal;
448
+ let result: SsmlSynthesisResult;
449
+ try {
450
+ result = await retryableSynthesis(
451
+ () =>
452
+ client.synthesizeSsml(input.ssml, {
453
+ outputFormat: options.outputFormat,
454
+ signal: chunkSignal,
455
+ timeoutMs: options.timeouts?.perChunkMs ?? options.timeoutMs,
456
+ sourceNodePath: input.sourceNodePath ?? options.sourceNodePath,
457
+ }),
458
+ options.retryOptions,
459
+ chunkSignal,
460
+ (retryAttempt, nextRetryDelayMs) =>
461
+ options.onProgress?.({
462
+ currentChunk: completed,
463
+ totalChunks: chunks.length,
464
+ percent: chunks.length === 0 ? 100 : Math.round((completed / chunks.length) * 100),
465
+ chunkIndex: index,
466
+ originalTextRange: input.originalTextRange,
467
+ status: "synthesizing",
468
+ durationMs: Date.now() - startedAt,
469
+ retryAttempt,
470
+ nextRetryDelayMs,
471
+ isRetrying: true,
472
+ }),
473
+ );
474
+ } catch (error) {
475
+ if (chunkScope?.timedOut())
476
+ throw new Error(`Speech synthesis timed out after ${chunkTimeout ?? options.timeoutMs} ms.`);
477
+ throw error;
478
+ } finally {
479
+ chunkScope?.dispose();
480
+ }
331
481
  results[index] = {
332
482
  ...result,
333
483
  ...(input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {}),
@@ -408,6 +558,7 @@ export async function synthesizeSsmlChunksSafe(
408
558
  durationMs: Date.now() - startedAt,
409
559
  });
410
560
  } catch (error) {
561
+ failedIndices.add(index);
411
562
  options.onProgress?.({
412
563
  currentChunk: completed,
413
564
  totalChunks: chunks.length,
@@ -418,24 +569,42 @@ export async function synthesizeSsmlChunksSafe(
418
569
  durationMs: Date.now() - startedAt,
419
570
  error,
420
571
  });
421
- throw error;
572
+ if (options.cancelOnFailure !== false) jobScope?.abort();
573
+ firstError ??= error;
574
+ return;
422
575
  }
423
576
  }
424
577
  };
425
578
  await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
579
+ if (failedIndices.size > 0) {
580
+ const error = firstError ?? new Error("One or more SSML chunks failed to synthesize.");
581
+ (error as { partialResult?: PartialChunkSynthesisResult }).partialResult = {
582
+ synthesizedChunks: results.flatMap((result, chunkIndex) => (result ? [{ ...result, chunkIndex }] : [])),
583
+ completedChunks: results.flatMap((result, chunkIndex) => (result ? [{ ...result, chunkIndex }] : [])),
584
+ pendingChunkIndices: chunks.flatMap((_chunk, chunkIndex) => (results[chunkIndex] ? [] : [chunkIndex])),
585
+ failedChunkIndices: [...failedIndices],
586
+ totalChunks: chunks.length,
587
+ };
588
+ throw error;
589
+ }
426
590
  const orderedResults = results.filter((result): result is SsmlSynthesisResult => result !== undefined);
427
591
  return {
428
592
  ok: true,
429
593
  success: true,
430
594
  status: "success",
431
- value: mergeSynthesisResults(orderedResults, {
595
+ value: await mergeSynthesisResults(orderedResults, {
432
596
  format: (options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3") as AzureTtsOutputFormat,
433
- signal: options.signal,
597
+ signal: jobScope?.signal ?? options.signal,
598
+ customMerger: options.customMerger,
599
+ outputMimeType: options.outputMimeType,
600
+ postMergeValidator: options.postMergeValidator,
434
601
  }),
435
602
  };
436
603
  } catch (error) {
437
604
  const synthesisError = toSynthesisError(error);
438
- return failure(synthesisError);
605
+ return failure(synthesisError, partialResultFrom(error));
606
+ } finally {
607
+ fallbackJobScope?.dispose();
439
608
  }
440
609
  }
441
610