@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/safe.ts CHANGED
@@ -10,15 +10,23 @@ 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
- import { mergeSynthesisResults } from "./synthesis.ts";
16
+ import { computeChunkFingerprint, mergeSynthesisResults } from "./synthesis.ts";
16
17
  import type {
17
18
  SsmlSynthesisChunk,
18
19
  SsmlSynthesisResult,
19
20
  SynthesisProgressEvent,
20
21
  SynthesizeChunksOptions,
21
22
  RetryOptions,
23
+ SynthesisTimeouts,
24
+ SynthesizedChunk,
25
+ PartialChunkSynthesisResult,
26
+ CustomAudioMerger,
27
+ PostMergeValidator,
28
+ ChunkExecutionState,
29
+ ResumeValidationMode,
22
30
  } from "./types.ts";
23
31
  import type { AzureTtsOutputFormat } from "./outputFormats.ts";
24
32
 
@@ -43,6 +51,29 @@ export class ChunkValidationError extends Error {
43
51
  }
44
52
  }
45
53
 
54
+ export interface ChunkDiagnostics {
55
+ readonly chunkIndex: number;
56
+ readonly diagnostics: readonly SsmlDiagnostic[];
57
+ }
58
+
59
+ export class BatchChunkValidationError extends ChunkValidationError {
60
+ readonly chunkDiagnostics: readonly ChunkDiagnostics[];
61
+ readonly totalErrorCount: number;
62
+ readonly errorCount: number;
63
+ readonly totalErrors: number;
64
+
65
+ constructor(chunkDiagnostics: readonly ChunkDiagnostics[]) {
66
+ const first = chunkDiagnostics[0];
67
+ super(first?.chunkIndex ?? -1, first?.diagnostics ?? []);
68
+ this.name = "BatchChunkValidationError";
69
+ this.message = `SSML validation failed for ${chunkDiagnostics.length} chunk(s); the Azure Speech API was not called.`;
70
+ this.chunkDiagnostics = chunkDiagnostics;
71
+ this.totalErrorCount = chunkDiagnostics.reduce((total, chunk) => total + chunk.diagnostics.length, 0);
72
+ this.errorCount = this.totalErrorCount;
73
+ this.totalErrors = this.totalErrorCount;
74
+ }
75
+ }
76
+
46
77
  export type Result<T, E> =
47
78
  | { readonly ok: true; readonly success: true; readonly status: "success"; readonly value: T }
48
79
  | (E extends { readonly kind: infer Kind extends SynthesisErrorKind }
@@ -51,18 +82,29 @@ export type Result<T, E> =
51
82
  readonly success: false;
52
83
  readonly status: Kind;
53
84
  readonly error: E;
85
+ readonly partialResult?: PartialChunkSynthesisResult;
54
86
  }
55
87
  : {
56
88
  readonly ok: false;
57
89
  readonly success: false;
58
90
  readonly status: SynthesisErrorKind;
59
91
  readonly error: E;
92
+ readonly partialResult?: PartialChunkSynthesisResult;
60
93
  });
61
94
 
62
95
  export type SynthesisResult<T, E> = Result<T, E>;
63
96
 
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>;
97
+ function failure<E extends { readonly kind: SynthesisErrorKind }>(
98
+ error: E,
99
+ partialResult?: PartialChunkSynthesisResult,
100
+ ): Result<never, E> {
101
+ return {
102
+ ok: false,
103
+ success: false,
104
+ status: error.kind,
105
+ error,
106
+ ...(partialResult ? { partialResult } : {}),
107
+ } as Result<never, E>;
66
108
  }
67
109
 
68
110
  export type Success<T> = Extract<Result<T, never>, { readonly ok: true }>;
@@ -81,6 +123,7 @@ export interface SynthesizeSsmlSafeOptions extends AzureValidationOptions {
81
123
  /** Optional nested form for callers that want to keep validation settings grouped. */
82
124
  validation?: AzureValidationOptions;
83
125
  signal?: AbortSignal;
126
+ timeouts?: SynthesisTimeouts;
84
127
  }
85
128
 
86
129
  export interface SynthesizeSsmlChunksSafeOptions extends AzureValidationOptions {
@@ -88,15 +131,24 @@ export interface SynthesizeSsmlChunksSafeOptions extends AzureValidationOptions
88
131
  outputFormat?: string;
89
132
  signal?: AbortSignal;
90
133
  timeoutMs?: number;
134
+ timeouts?: SynthesisTimeouts;
91
135
  sourceNodePath?: string[];
92
136
  onProgress?: (event: SynthesisProgressEvent) => void;
93
137
  concurrency?: number;
94
138
  retryOptions?: RetryOptions;
139
+ cancelOnFailure?: boolean;
140
+ resumeChunks?: readonly SynthesizedChunk[];
141
+ resumeChunkIndices?: readonly number[];
142
+ customMerger?: CustomAudioMerger;
143
+ outputMimeType?: string;
144
+ postMergeValidator?: PostMergeValidator;
145
+ resumeValidation?: ResumeValidationMode;
95
146
  }
96
147
 
97
148
  export type SsmlSynthesisChunksSafeResult =
98
149
  | Result<SsmlSynthesisResult, never>
99
150
  | Result<never, ChunkValidationError>
151
+ | Result<never, BatchChunkValidationError>
100
152
  | Result<never, SsmlSynthesisError | ChunkValidationError>;
101
153
 
102
154
  interface SynthesisClient {
@@ -107,6 +159,44 @@ interface SynthesisClient {
107
159
  ): Promise<SsmlSynthesisResult>;
108
160
  }
109
161
 
162
+ function partialResultFrom(error: unknown): PartialChunkSynthesisResult | undefined {
163
+ if (!error || typeof error !== "object") return undefined;
164
+ const partial = (error as { partialResult?: unknown }).partialResult;
165
+ if (!partial || typeof partial !== "object") return undefined;
166
+ return partial as PartialChunkSynthesisResult;
167
+ }
168
+
169
+ interface SafeAbortScope {
170
+ signal: AbortSignal;
171
+ timedOut: () => boolean;
172
+ dispose: () => void;
173
+ abort: () => void;
174
+ }
175
+
176
+ function createSafeAbortScope(parent: AbortSignal | undefined, timeoutMs: number | undefined): SafeAbortScope {
177
+ const controller = new AbortController();
178
+ let didTimeout = false;
179
+ const onAbort = () => controller.abort();
180
+ if (parent?.aborted) controller.abort();
181
+ parent?.addEventListener("abort", onAbort, { once: true });
182
+ const timer =
183
+ timeoutMs !== undefined && timeoutMs > 0
184
+ ? setTimeout(() => {
185
+ didTimeout = true;
186
+ controller.abort();
187
+ }, timeoutMs)
188
+ : undefined;
189
+ return {
190
+ signal: controller.signal,
191
+ timedOut: () => didTimeout,
192
+ dispose: () => {
193
+ if (timer) clearTimeout(timer);
194
+ parent?.removeEventListener("abort", onAbort);
195
+ },
196
+ abort: () => controller.abort(),
197
+ };
198
+ }
199
+
110
200
  function isRetryable(error: unknown): boolean {
111
201
  if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
112
202
  const status =
@@ -123,6 +213,10 @@ function delayForRetry(options: RetryOptions, attempt: number): number {
123
213
  return Math.floor(Math.random() * (base + 1));
124
214
  }
125
215
 
216
+ function retryDelayForError(options: RetryOptions, attempt: number, error: unknown): number {
217
+ return getRetryAfterDelayMs(error) ?? delayForRetry(options, attempt);
218
+ }
219
+
126
220
  function resolveConcurrency(value: number | undefined, total: number): number {
127
221
  if (value === undefined) return 1;
128
222
  if (value === Infinity) return Math.max(1, total);
@@ -134,12 +228,14 @@ async function retryableSynthesis(
134
228
  options: RetryOptions | undefined,
135
229
  signal: AbortSignal | undefined,
136
230
  onRetry: (attempt: number, delayMs: number) => void,
231
+ deadlineAtMs?: number,
137
232
  ): Promise<SsmlSynthesisResult> {
138
233
  const retry = options
139
234
  ? {
140
235
  maxRetries: Math.max(0, Math.floor(options.maxRetries)),
141
236
  initialDelayMs: options.initialDelayMs,
142
237
  maxDelayMs: options.maxDelayMs,
238
+ shouldRetry: options.shouldRetry,
143
239
  }
144
240
  : undefined;
145
241
  let attempt = 0;
@@ -148,9 +244,18 @@ async function retryableSynthesis(
148
244
  try {
149
245
  return await synthesize();
150
246
  } catch (error) {
151
- if (!retry || attempt >= retry.maxRetries || !isRetryable(error)) throw error;
247
+ if (!retry || attempt >= retry.maxRetries || !(retry.shouldRetry?.(error, attempt + 1) ?? isRetryable(error)))
248
+ throw error;
152
249
  attempt += 1;
153
- const delayMs = delayForRetry(retry, attempt);
250
+ const delayMs = retryDelayForError(retry, attempt, error);
251
+ const retryAfterMs = getRetryAfterDelayMs(error);
252
+ const remainingMs = deadlineAtMs === undefined ? undefined : Math.max(0, deadlineAtMs - Date.now());
253
+ if (
254
+ retryAfterMs !== undefined &&
255
+ (retryAfterMs > retry.maxDelayMs || (remainingMs !== undefined && retryAfterMs > remainingMs))
256
+ ) {
257
+ throw new Error("Speech synthesis timed out because Retry-After exceeded the available retry budget.");
258
+ }
154
259
  onRetry(attempt, delayMs);
155
260
  if (delayMs > 0)
156
261
  await new Promise<void>((resolve, reject) => {
@@ -169,13 +274,20 @@ async function retryableSynthesis(
169
274
  }
170
275
  }
171
276
 
172
- function sharedValidationOptions(options: AzureValidationOptions, signal?: AbortSignal): AzureValidationOptions {
277
+ function sharedValidationOptions(
278
+ options: AzureValidationOptions & { timeouts?: SynthesisTimeouts },
279
+ signal?: AbortSignal,
280
+ ): AzureValidationOptions {
173
281
  const validator = options.urlValidator ?? options.customUrlValidator;
174
282
  if (!validator) return signal ? withValidationSignal(options, signal) : options;
175
283
  const runner = createAzureUrlValidatorRunner(validator as AzureUrlValidator, {
176
284
  ...(options.urlValidation ?? {}),
177
285
  ...(options.urlValidatorConcurrency !== undefined ? { concurrency: options.urlValidatorConcurrency } : {}),
178
- ...(options.urlValidatorTimeoutMs !== undefined ? { timeoutMs: options.urlValidatorTimeoutMs } : {}),
286
+ ...(options.timeouts?.urlValidationMs !== undefined
287
+ ? { timeoutMs: options.timeouts.urlValidationMs }
288
+ : options.urlValidatorTimeoutMs !== undefined
289
+ ? { timeoutMs: options.urlValidatorTimeoutMs }
290
+ : {}),
179
291
  ...(signal ? { signal } : {}),
180
292
  ...(options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}),
181
293
  });
@@ -206,16 +318,27 @@ export async function synthesizeSsmlSafe(
206
318
  });
207
319
  }
208
320
 
321
+ const jobScope =
322
+ options.timeouts?.totalJobMs !== undefined
323
+ ? createSafeAbortScope(options.signal, options.timeouts.totalJobMs)
324
+ : undefined;
209
325
  try {
210
326
  return {
211
327
  ok: true,
212
328
  success: true,
213
329
  status: "success",
214
- value: await client.synthesizeSsml(ssml, { signal: options.signal }),
330
+ value: await client.synthesizeSsml(ssml, {
331
+ signal: jobScope?.signal ?? options.signal,
332
+ timeoutMs: options.timeouts?.perChunkMs,
333
+ timeouts: options.timeouts,
334
+ }),
215
335
  };
216
336
  } catch (error) {
337
+ if (jobScope?.timedOut()) return failure(toSynthesisError(new Error("Speech synthesis timed out.")));
217
338
  const synthesisError = toSynthesisError(error);
218
339
  return failure(synthesisError);
340
+ } finally {
341
+ jobScope?.dispose();
219
342
  }
220
343
  }
221
344
 
@@ -225,7 +348,10 @@ export async function synthesizeSsmlChunksSafe(
225
348
  chunks: readonly (SsmlSynthesisChunk | string)[],
226
349
  options: SynthesizeSsmlChunksSafeOptions = {},
227
350
  ): Promise<SsmlSynthesisChunksSafeResult> {
228
- const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
351
+ const validationOptions = sharedValidationOptions(
352
+ { ...(options.validation ?? options), timeouts: options.timeouts },
353
+ options.signal,
354
+ );
229
355
  if (options.signal?.aborted) {
230
356
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
231
357
  return failure(error);
@@ -261,17 +387,20 @@ export async function synthesizeSsmlChunksSafe(
261
387
  return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
262
388
  }),
263
389
  );
264
- const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
265
390
  if (options.signal?.aborted) {
266
391
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
267
392
  return failure(error);
268
393
  }
269
- if (firstInvalidIndex >= 0) {
270
- const error = new ChunkValidationError(firstInvalidIndex, validations[firstInvalidIndex] ?? []);
271
- pending(firstInvalidIndex, "failed", error);
394
+ const chunkDiagnostics = validations
395
+ .map((diagnostics, chunkIndex) => ({ chunkIndex, diagnostics }))
396
+ .filter((entry) => entry.diagnostics.length > 0);
397
+ if (chunkDiagnostics.length > 0) {
398
+ const error = new BatchChunkValidationError(chunkDiagnostics);
399
+ for (const entry of chunkDiagnostics) pending(entry.chunkIndex, "failed", error);
272
400
  return failure(error);
273
401
  }
274
402
 
403
+ let fallbackJobScope: SafeAbortScope | undefined;
275
404
  try {
276
405
  if (client.synthesizeChunks) {
277
406
  const normalizedChunks = chunks.map((chunk) => {
@@ -283,20 +412,67 @@ export async function synthesizeSsmlChunksSafe(
283
412
  outputFormat: options.outputFormat,
284
413
  signal: options.signal,
285
414
  timeoutMs: options.timeoutMs,
415
+ timeouts: options.timeouts,
286
416
  sourceNodePath: options.sourceNodePath,
287
417
  concurrency: options.concurrency,
288
418
  retryOptions: options.retryOptions,
419
+ cancelOnFailure: options.cancelOnFailure,
420
+ resumeChunks: options.resumeChunks,
421
+ resumeChunkIndices: options.resumeChunkIndices,
422
+ customMerger: options.customMerger,
423
+ outputMimeType: options.outputMimeType,
424
+ postMergeValidator: options.postMergeValidator,
425
+ resumeValidation: options.resumeValidation,
289
426
  });
290
427
  return { ok: true, success: true, status: "success", value };
291
428
  }
429
+ const inputs = chunks.map((chunk) => (typeof chunk === "string" ? { ssml: chunk } : chunk));
430
+ const fingerprints = inputs.map((chunk) => computeChunkFingerprint(chunk.ssml, options.outputFormat));
292
431
  const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
293
- let completed = 0;
432
+ const chunkStates: ChunkExecutionState[] = inputs.map((_chunk, chunkIndex) => ({
433
+ chunkIndex,
434
+ status: "pending",
435
+ canResume: true,
436
+ }));
437
+ const cachedChunks = new Map((options.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
438
+ const invalidCachedIndices = new Set<number>();
439
+ for (const [index, cached] of cachedChunks) {
440
+ if (index < 0 || index >= chunks.length) continue;
441
+ if (options.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index]) {
442
+ results[index] = cached;
443
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: cached };
444
+ } else invalidCachedIndices.add(index);
445
+ }
446
+ const requestedIndices = options.resumeChunkIndices
447
+ ? new Set(options.resumeChunkIndices.filter((index) => index >= 0 && index < chunks.length))
448
+ : undefined;
449
+ const shouldSynthesize = (index: number): boolean =>
450
+ (!cachedChunks.has(index) || invalidCachedIndices.has(index)) &&
451
+ (requestedIndices === undefined || requestedIndices.has(index) || invalidCachedIndices.has(index));
452
+ const jobStartedAt = Date.now();
453
+ const jobDeadlineAt =
454
+ options.timeouts?.totalJobMs !== undefined && options.timeouts.totalJobMs > 0
455
+ ? jobStartedAt + options.timeouts.totalJobMs
456
+ : undefined;
457
+ const jobScope =
458
+ chunks.length > 1 || options.timeouts?.totalJobMs !== undefined
459
+ ? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs)
460
+ : undefined;
461
+ fallbackJobScope = jobScope;
462
+ const failedIndices = new Set<number>();
463
+ let firstError: unknown;
464
+ let completed = [...results].filter((result) => result !== undefined).length;
294
465
  let nextIndex = 0;
295
466
  const concurrency = resolveConcurrency(options.concurrency, chunks.length);
296
467
  const worker = async (): Promise<void> => {
297
468
  while (true) {
298
469
  const index = nextIndex++;
299
470
  if (index >= chunks.length) return;
471
+ if (!shouldSynthesize(index)) continue;
472
+ if (firstError && options.cancelOnFailure !== false) {
473
+ chunkStates[index] = { chunkIndex: index, status: "cancelled", isOriginalFailure: false, canResume: true };
474
+ return;
475
+ }
300
476
  const chunk = chunks[index];
301
477
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
302
478
  const sourceNodePath = input.sourceNodePath;
@@ -304,30 +480,46 @@ export async function synthesizeSsmlChunksSafe(
304
480
  pending(index, "synthesizing");
305
481
  const startedAt = Date.now();
306
482
  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
- );
483
+ const chunkTimeout = options.timeouts?.chunkWithRetriesMs ?? options.timeouts?.perChunkMs;
484
+ const chunkScope =
485
+ chunkTimeout !== undefined || jobScope
486
+ ? createSafeAbortScope(jobScope?.signal ?? options.signal, chunkTimeout ?? options.timeoutMs)
487
+ : undefined;
488
+ const chunkSignal = chunkScope?.signal ?? options.signal;
489
+ let result: SsmlSynthesisResult;
490
+ try {
491
+ result = await retryableSynthesis(
492
+ () =>
493
+ client.synthesizeSsml(input.ssml, {
494
+ outputFormat: options.outputFormat,
495
+ signal: chunkSignal,
496
+ timeoutMs: options.timeouts?.perChunkMs ?? options.timeoutMs,
497
+ sourceNodePath: input.sourceNodePath ?? options.sourceNodePath,
498
+ }),
499
+ options.retryOptions,
500
+ chunkSignal,
501
+ (retryAttempt, nextRetryDelayMs) =>
502
+ options.onProgress?.({
503
+ currentChunk: completed,
504
+ totalChunks: chunks.length,
505
+ percent: chunks.length === 0 ? 100 : Math.round((completed / chunks.length) * 100),
506
+ chunkIndex: index,
507
+ originalTextRange: input.originalTextRange,
508
+ status: "synthesizing",
509
+ durationMs: Date.now() - startedAt,
510
+ retryAttempt,
511
+ nextRetryDelayMs,
512
+ isRetrying: true,
513
+ }),
514
+ jobDeadlineAt,
515
+ );
516
+ } catch (error) {
517
+ if (chunkScope?.timedOut())
518
+ throw new Error(`Speech synthesis timed out after ${chunkTimeout ?? options.timeoutMs} ms.`);
519
+ throw error;
520
+ } finally {
521
+ chunkScope?.dispose();
522
+ }
331
523
  results[index] = {
332
524
  ...result,
333
525
  ...(input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {}),
@@ -397,6 +589,7 @@ export async function synthesizeSsmlChunksSafe(
397
589
  }
398
590
  : {}),
399
591
  };
592
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
400
593
  completed += 1;
401
594
  options.onProgress?.({
402
595
  currentChunk: completed,
@@ -408,6 +601,16 @@ export async function synthesizeSsmlChunksSafe(
408
601
  durationMs: Date.now() - startedAt,
409
602
  });
410
603
  } catch (error) {
604
+ const wasCancelled = firstError !== undefined || Boolean(jobScope?.signal.aborted && !jobScope?.timedOut());
605
+ firstError ??= error;
606
+ if (!wasCancelled) failedIndices.add(index);
607
+ chunkStates[index] = {
608
+ chunkIndex: index,
609
+ status: wasCancelled ? "cancelled" : "failed",
610
+ isOriginalFailure: !wasCancelled,
611
+ canResume: true,
612
+ error: error as ChunkExecutionState["error"],
613
+ };
411
614
  options.onProgress?.({
412
615
  currentChunk: completed,
413
616
  totalChunks: chunks.length,
@@ -418,24 +621,59 @@ export async function synthesizeSsmlChunksSafe(
418
621
  durationMs: Date.now() - startedAt,
419
622
  error,
420
623
  });
421
- throw error;
624
+ if (options.cancelOnFailure !== false) jobScope?.abort();
625
+ return;
422
626
  }
423
627
  }
424
628
  };
425
629
  await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
630
+ if (firstError && options.cancelOnFailure !== false) {
631
+ for (const [chunkIndex, state] of chunkStates.entries()) {
632
+ if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
633
+ chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
634
+ }
635
+ }
636
+ }
637
+ if (failedIndices.size > 0) {
638
+ const error = firstError ?? new Error("One or more SSML chunks failed to synthesize.");
639
+ const synthesizedChunks = results.flatMap((result, chunkIndex) =>
640
+ result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : [],
641
+ );
642
+ (error as { partialResult?: PartialChunkSynthesisResult }).partialResult = {
643
+ synthesizedChunks,
644
+ completedChunks: synthesizedChunks,
645
+ pendingChunkIndices: chunkStates.flatMap((state) =>
646
+ state.status === "pending" || state.status === "cancelled" || state.status === "failed"
647
+ ? [state.chunkIndex]
648
+ : [],
649
+ ),
650
+ failedChunkIndices: [...failedIndices],
651
+ cancelledChunkIndices: chunkStates
652
+ .filter((state) => state.status === "cancelled")
653
+ .map((state) => state.chunkIndex),
654
+ chunkStates,
655
+ totalChunks: chunks.length,
656
+ };
657
+ throw error;
658
+ }
426
659
  const orderedResults = results.filter((result): result is SsmlSynthesisResult => result !== undefined);
427
660
  return {
428
661
  ok: true,
429
662
  success: true,
430
663
  status: "success",
431
- value: mergeSynthesisResults(orderedResults, {
664
+ value: await mergeSynthesisResults(orderedResults, {
432
665
  format: (options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3") as AzureTtsOutputFormat,
433
- signal: options.signal,
666
+ signal: jobScope?.signal ?? options.signal,
667
+ customMerger: options.customMerger,
668
+ outputMimeType: options.outputMimeType,
669
+ postMergeValidator: options.postMergeValidator,
434
670
  }),
435
671
  };
436
672
  } catch (error) {
437
673
  const synthesisError = toSynthesisError(error);
438
- return failure(synthesisError);
674
+ return failure(synthesisError, partialResultFrom(error));
675
+ } finally {
676
+ fallbackJobScope?.dispose();
439
677
  }
440
678
  }
441
679