@ssml-builder-js/azure-tts-client 2.15.0 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/safe.ts CHANGED
@@ -1,9 +1,16 @@
1
- import { validateAzureSsml, type AzureValidationOptions, type SsmlDiagnostic } from "@ssml-builder-js/ssml-core";
1
+ import {
2
+ createAzureUrlValidatorRunner,
3
+ validateAzureSsml,
4
+ type AzureUrlValidator,
5
+ type AzureValidationOptions,
6
+ type SsmlDiagnostic,
7
+ } from "@ssml-builder-js/ssml-core";
2
8
  import {
3
9
  type AzureTtsError,
4
10
  type AzureTtsSynthesisError,
5
11
  type SynthesisErrorKind,
6
12
  toSynthesisError,
13
+ getRetryAfterDelayMs,
7
14
  } from "./errors.ts";
8
15
  import type { AzureTtsClient } from "./client.ts";
9
16
  import { mergeSynthesisResults } from "./synthesis.ts";
@@ -12,6 +19,12 @@ import type {
12
19
  SsmlSynthesisResult,
13
20
  SynthesisProgressEvent,
14
21
  SynthesizeChunksOptions,
22
+ RetryOptions,
23
+ SynthesisTimeouts,
24
+ SynthesizedChunk,
25
+ PartialChunkSynthesisResult,
26
+ CustomAudioMerger,
27
+ PostMergeValidator,
15
28
  } from "./types.ts";
16
29
  import type { AzureTtsOutputFormat } from "./outputFormats.ts";
17
30
 
@@ -36,6 +49,29 @@ export class ChunkValidationError extends Error {
36
49
  }
37
50
  }
38
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
+
39
75
  export type Result<T, E> =
40
76
  | { readonly ok: true; readonly success: true; readonly status: "success"; readonly value: T }
41
77
  | (E extends { readonly kind: infer Kind extends SynthesisErrorKind }
@@ -44,18 +80,29 @@ export type Result<T, E> =
44
80
  readonly success: false;
45
81
  readonly status: Kind;
46
82
  readonly error: E;
83
+ readonly partialResult?: PartialChunkSynthesisResult;
47
84
  }
48
85
  : {
49
86
  readonly ok: false;
50
87
  readonly success: false;
51
88
  readonly status: SynthesisErrorKind;
52
89
  readonly error: E;
90
+ readonly partialResult?: PartialChunkSynthesisResult;
53
91
  });
54
92
 
55
93
  export type SynthesisResult<T, E> = Result<T, E>;
56
94
 
57
- function failure<E extends { readonly kind: SynthesisErrorKind }>(error: E): Result<never, E> {
58
- 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>;
59
106
  }
60
107
 
61
108
  export type Success<T> = Extract<Result<T, never>, { readonly ok: true }>;
@@ -74,6 +121,7 @@ export interface SynthesizeSsmlSafeOptions extends AzureValidationOptions {
74
121
  /** Optional nested form for callers that want to keep validation settings grouped. */
75
122
  validation?: AzureValidationOptions;
76
123
  signal?: AbortSignal;
124
+ timeouts?: SynthesisTimeouts;
77
125
  }
78
126
 
79
127
  export interface SynthesizeSsmlChunksSafeOptions extends AzureValidationOptions {
@@ -81,13 +129,23 @@ export interface SynthesizeSsmlChunksSafeOptions extends AzureValidationOptions
81
129
  outputFormat?: string;
82
130
  signal?: AbortSignal;
83
131
  timeoutMs?: number;
132
+ timeouts?: SynthesisTimeouts;
84
133
  sourceNodePath?: string[];
85
134
  onProgress?: (event: SynthesisProgressEvent) => void;
135
+ concurrency?: number;
136
+ retryOptions?: RetryOptions;
137
+ cancelOnFailure?: boolean;
138
+ resumeChunks?: readonly SynthesizedChunk[];
139
+ resumeChunkIndices?: readonly number[];
140
+ customMerger?: CustomAudioMerger;
141
+ outputMimeType?: string;
142
+ postMergeValidator?: PostMergeValidator;
86
143
  }
87
144
 
88
145
  export type SsmlSynthesisChunksSafeResult =
89
146
  | Result<SsmlSynthesisResult, never>
90
147
  | Result<never, ChunkValidationError>
148
+ | Result<never, BatchChunkValidationError>
91
149
  | Result<never, SsmlSynthesisError | ChunkValidationError>;
92
150
 
93
151
  interface SynthesisClient {
@@ -98,13 +156,142 @@ interface SynthesisClient {
98
156
  ): Promise<SsmlSynthesisResult>;
99
157
  }
100
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
+
197
+ function isRetryable(error: unknown): boolean {
198
+ if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
199
+ const status =
200
+ error && typeof error === "object" && "status" in error ? (error as { status?: unknown }).status : undefined;
201
+ if (typeof status === "number" && status !== 0) return status === 429 || (status >= 500 && status < 600);
202
+ const message = error instanceof Error ? error.message : String(error);
203
+ if (/\b4\d{2}\b/.test(message)) return false;
204
+ return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
205
+ }
206
+
207
+ function delayForRetry(options: RetryOptions, attempt: number): number {
208
+ const maxDelay = Math.max(0, options.maxDelayMs);
209
+ const base = Math.min(maxDelay, Math.max(0, options.initialDelayMs) * 2 ** Math.max(0, attempt - 1));
210
+ return Math.floor(Math.random() * (base + 1));
211
+ }
212
+
213
+ function retryDelayForError(options: RetryOptions, attempt: number, error: unknown): number {
214
+ return getRetryAfterDelayMs(error) ?? delayForRetry(options, attempt);
215
+ }
216
+
217
+ function resolveConcurrency(value: number | undefined, total: number): number {
218
+ if (value === undefined) return 1;
219
+ if (value === Infinity) return Math.max(1, total);
220
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
221
+ }
222
+
223
+ async function retryableSynthesis(
224
+ synthesize: () => Promise<SsmlSynthesisResult>,
225
+ options: RetryOptions | undefined,
226
+ signal: AbortSignal | undefined,
227
+ onRetry: (attempt: number, delayMs: number) => void,
228
+ ): Promise<SsmlSynthesisResult> {
229
+ const retry = options
230
+ ? {
231
+ maxRetries: Math.max(0, Math.floor(options.maxRetries)),
232
+ initialDelayMs: options.initialDelayMs,
233
+ maxDelayMs: options.maxDelayMs,
234
+ shouldRetry: options.shouldRetry,
235
+ }
236
+ : undefined;
237
+ let attempt = 0;
238
+ while (true) {
239
+ if (signal?.aborted) throw new Error("Speech synthesis was cancelled.");
240
+ try {
241
+ return await synthesize();
242
+ } catch (error) {
243
+ if (!retry || attempt >= retry.maxRetries || !(retry.shouldRetry?.(error, attempt + 1) ?? isRetryable(error)))
244
+ throw error;
245
+ attempt += 1;
246
+ const delayMs = retryDelayForError(retry, attempt, error);
247
+ onRetry(attempt, delayMs);
248
+ if (delayMs > 0)
249
+ await new Promise<void>((resolve, reject) => {
250
+ const timer = setTimeout(() => {
251
+ signal?.removeEventListener("abort", abort);
252
+ resolve();
253
+ }, delayMs);
254
+ const abort = () => {
255
+ clearTimeout(timer);
256
+ signal?.removeEventListener("abort", abort);
257
+ reject(new Error("Speech synthesis was cancelled."));
258
+ };
259
+ signal?.addEventListener("abort", abort, { once: true });
260
+ });
261
+ }
262
+ }
263
+ }
264
+
265
+ function sharedValidationOptions(
266
+ options: AzureValidationOptions & { timeouts?: SynthesisTimeouts },
267
+ signal?: AbortSignal,
268
+ ): AzureValidationOptions {
269
+ const validator = options.urlValidator ?? options.customUrlValidator;
270
+ if (!validator) return signal ? withValidationSignal(options, signal) : options;
271
+ const runner = createAzureUrlValidatorRunner(validator as AzureUrlValidator, {
272
+ ...(options.urlValidation ?? {}),
273
+ ...(options.urlValidatorConcurrency !== undefined ? { concurrency: options.urlValidatorConcurrency } : {}),
274
+ ...(options.timeouts?.urlValidationMs !== undefined
275
+ ? { timeoutMs: options.timeouts.urlValidationMs }
276
+ : options.urlValidatorTimeoutMs !== undefined
277
+ ? { timeoutMs: options.urlValidatorTimeoutMs }
278
+ : {}),
279
+ ...(signal ? { signal } : {}),
280
+ ...(options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}),
281
+ });
282
+ return {
283
+ ...withValidationSignal(options, signal),
284
+ urlValidatorRunner: runner,
285
+ };
286
+ }
287
+
101
288
  /** Validates SSML before invoking Azure and converts validation/API failures to one result shape. */
102
289
  export async function synthesizeSsmlSafe(
103
290
  client: Pick<AzureTtsClient, "synthesizeSsml"> | SynthesisClient,
104
291
  ssml: string,
105
292
  options: SynthesizeSsmlSafeOptions = {},
106
293
  ): Promise<SsmlSynthesisSafeResult> {
107
- const validationOptions = withValidationSignal(options.validation ?? options, options.signal);
294
+ const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
108
295
  const diagnostics = await Promise.resolve(validateAzureSsml(ssml, validationOptions));
109
296
  if (options.signal?.aborted) {
110
297
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
@@ -124,7 +311,11 @@ export async function synthesizeSsmlSafe(
124
311
  ok: true,
125
312
  success: true,
126
313
  status: "success",
127
- 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
+ }),
128
319
  };
129
320
  } catch (error) {
130
321
  const synthesisError = toSynthesisError(error);
@@ -138,7 +329,10 @@ export async function synthesizeSsmlChunksSafe(
138
329
  chunks: readonly (SsmlSynthesisChunk | string)[],
139
330
  options: SynthesizeSsmlChunksSafeOptions = {},
140
331
  ): Promise<SsmlSynthesisChunksSafeResult> {
141
- const validationOptions = withValidationSignal(options.validation ?? options, options.signal);
332
+ const validationOptions = sharedValidationOptions(
333
+ { ...(options.validation ?? options), timeouts: options.timeouts },
334
+ options.signal,
335
+ );
142
336
  if (options.signal?.aborted) {
143
337
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
144
338
  return failure(error);
@@ -160,23 +354,34 @@ export async function synthesizeSsmlChunksSafe(
160
354
  pending(index, "pending");
161
355
  });
162
356
  const validations = await Promise.all(
163
- chunks.map(async (chunk) => {
357
+ chunks.map(async (chunk, index) => {
164
358
  const ssml = typeof chunk === "string" ? chunk : chunk.ssml;
165
359
  const sourceNodePath =
166
360
  typeof chunk === "string" ? options.sourceNodePath : (chunk.sourceNodePath ?? options.sourceNodePath);
167
361
  const diagnostics = await Promise.resolve(
168
- validateAzureSsml(ssml, { ...validationOptions, ...(sourceNodePath ? { sourceNodePath } : {}) }),
362
+ validateAzureSsml(ssml, {
363
+ ...validationOptions,
364
+ ...(sourceNodePath ? { sourceNodePath } : {}),
365
+ chunkIndex: index,
366
+ }),
169
367
  );
170
368
  return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
171
369
  }),
172
370
  );
173
- const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
174
- if (firstInvalidIndex >= 0) {
175
- const error = new ChunkValidationError(firstInvalidIndex, validations[firstInvalidIndex] ?? []);
176
- pending(firstInvalidIndex, "failed", error);
371
+ if (options.signal?.aborted) {
372
+ const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
373
+ return failure(error);
374
+ }
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);
177
381
  return failure(error);
178
382
  }
179
383
 
384
+ let fallbackJobScope: SafeAbortScope | undefined;
180
385
  try {
181
386
  if (client.synthesizeChunks) {
182
387
  const normalizedChunks = chunks.map((chunk) => {
@@ -188,127 +393,218 @@ export async function synthesizeSsmlChunksSafe(
188
393
  outputFormat: options.outputFormat,
189
394
  signal: options.signal,
190
395
  timeoutMs: options.timeoutMs,
396
+ timeouts: options.timeouts,
191
397
  sourceNodePath: options.sourceNodePath,
398
+ concurrency: options.concurrency,
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,
192
406
  });
193
407
  return { ok: true, success: true, status: "success", value };
194
408
  }
195
- const results: SsmlSynthesisResult[] = [];
196
- for (const [index, chunk] of chunks.entries()) {
197
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
198
- const sourceNodePath = input.sourceNodePath;
199
- const originalTextRange = input.originalTextRange;
200
- pending(index, "synthesizing");
201
- const startedAt = Date.now();
202
- try {
203
- const result = await client.synthesizeSsml(input.ssml, {
204
- outputFormat: options.outputFormat,
205
- signal: options.signal,
206
- timeoutMs: options.timeoutMs,
207
- sourceNodePath: input.sourceNodePath ?? options.sourceNodePath,
208
- });
209
- results.push({
210
- ...result,
211
- ...(input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {}),
212
- ...(sourceNodePath
213
- ? {
214
- boundaries: result.boundaries?.map((event) => ({
215
- ...event,
216
- sourceNodePath: [...sourceNodePath],
217
- ...(event.originalTextRange
218
- ? { originalTextRange: { ...event.originalTextRange } }
219
- : input.originalTextRange
220
- ? { originalTextRange: { ...input.originalTextRange } }
221
- : {}),
222
- })),
223
- visemes: result.visemes?.map((event) => ({
224
- ...event,
225
- sourceNodePath: [...sourceNodePath],
226
- ...(event.originalTextRange
227
- ? { originalTextRange: { ...event.originalTextRange } }
228
- : input.originalTextRange
229
- ? { originalTextRange: { ...input.originalTextRange } }
230
- : {}),
231
- })),
232
- bookmarks: result.bookmarks?.map((event) => ({
233
- ...event,
234
- sourceNodePath: [...sourceNodePath],
235
- ...(event.originalTextRange
236
- ? { originalTextRange: { ...event.originalTextRange } }
237
- : input.originalTextRange
238
- ? { originalTextRange: { ...input.originalTextRange } }
239
- : {}),
240
- })),
241
- }
242
- : {}),
243
- ...(originalTextRange
244
- ? {
245
- boundaries: result.boundaries?.map((event) => ({
246
- ...event,
247
- originalTextRange: event.originalTextRange
248
- ? { ...event.originalTextRange }
249
- : { ...originalTextRange },
250
- })),
251
- wordBoundary: result.wordBoundary?.map((event) => ({
252
- ...event,
253
- originalTextRange: event.originalTextRange
254
- ? { ...event.originalTextRange }
255
- : { ...originalTextRange },
256
- })),
257
- wordBoundaries: result.wordBoundaries?.map((event) => ({
258
- ...event,
259
- originalTextRange: event.originalTextRange
260
- ? { ...event.originalTextRange }
261
- : { ...originalTextRange },
262
- })),
263
- visemes: result.visemes?.map((event) => ({
264
- ...event,
265
- originalTextRange: event.originalTextRange
266
- ? { ...event.originalTextRange }
267
- : { ...originalTextRange },
268
- })),
269
- bookmarks: result.bookmarks?.map((event) => ({
270
- ...event,
271
- originalTextRange: event.originalTextRange
272
- ? { ...event.originalTextRange }
273
- : { ...originalTextRange },
274
- })),
275
- }
276
- : {}),
277
- });
278
- options.onProgress?.({
279
- currentChunk: index + 1,
280
- totalChunks: chunks.length,
281
- percent: chunks.length === 0 ? 100 : Math.round(((index + 1) / chunks.length) * 100),
282
- chunkIndex: index,
283
- originalTextRange: input.originalTextRange,
284
- status: "success",
285
- durationMs: Date.now() - startedAt,
286
- });
287
- } catch (error) {
288
- options.onProgress?.({
289
- currentChunk: index,
290
- totalChunks: chunks.length,
291
- percent: chunks.length === 0 ? 100 : Math.round((index / chunks.length) * 100),
292
- chunkIndex: index,
293
- originalTextRange: input.originalTextRange,
294
- status: "failed",
295
- durationMs: Date.now() - startedAt,
296
- error,
297
- });
298
- throw error;
409
+ const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
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;
427
+ let nextIndex = 0;
428
+ const concurrency = resolveConcurrency(options.concurrency, chunks.length);
429
+ const worker = async (): Promise<void> => {
430
+ while (true) {
431
+ const index = nextIndex++;
432
+ if (index >= chunks.length) return;
433
+ if (!shouldSynthesize(index)) continue;
434
+ if (failedIndices.size > 0 && options.cancelOnFailure !== false) return;
435
+ const chunk = chunks[index];
436
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
437
+ const sourceNodePath = input.sourceNodePath;
438
+ const originalTextRange = input.originalTextRange;
439
+ pending(index, "synthesizing");
440
+ const startedAt = Date.now();
441
+ try {
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
+ }
481
+ results[index] = {
482
+ ...result,
483
+ ...(input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {}),
484
+ ...(sourceNodePath
485
+ ? {
486
+ boundaries: result.boundaries?.map((event) => ({
487
+ ...event,
488
+ sourceNodePath: [...sourceNodePath],
489
+ ...(event.originalTextRange
490
+ ? { originalTextRange: { ...event.originalTextRange } }
491
+ : input.originalTextRange
492
+ ? { originalTextRange: { ...input.originalTextRange } }
493
+ : {}),
494
+ })),
495
+ visemes: result.visemes?.map((event) => ({
496
+ ...event,
497
+ sourceNodePath: [...sourceNodePath],
498
+ ...(event.originalTextRange
499
+ ? { originalTextRange: { ...event.originalTextRange } }
500
+ : input.originalTextRange
501
+ ? { originalTextRange: { ...input.originalTextRange } }
502
+ : {}),
503
+ })),
504
+ bookmarks: result.bookmarks?.map((event) => ({
505
+ ...event,
506
+ sourceNodePath: [...sourceNodePath],
507
+ ...(event.originalTextRange
508
+ ? { originalTextRange: { ...event.originalTextRange } }
509
+ : input.originalTextRange
510
+ ? { originalTextRange: { ...input.originalTextRange } }
511
+ : {}),
512
+ })),
513
+ }
514
+ : {}),
515
+ ...(originalTextRange
516
+ ? {
517
+ boundaries: result.boundaries?.map((event) => ({
518
+ ...event,
519
+ originalTextRange: event.originalTextRange
520
+ ? { ...event.originalTextRange }
521
+ : { ...originalTextRange },
522
+ })),
523
+ wordBoundary: result.wordBoundary?.map((event) => ({
524
+ ...event,
525
+ originalTextRange: event.originalTextRange
526
+ ? { ...event.originalTextRange }
527
+ : { ...originalTextRange },
528
+ })),
529
+ wordBoundaries: result.wordBoundaries?.map((event) => ({
530
+ ...event,
531
+ originalTextRange: event.originalTextRange
532
+ ? { ...event.originalTextRange }
533
+ : { ...originalTextRange },
534
+ })),
535
+ visemes: result.visemes?.map((event) => ({
536
+ ...event,
537
+ originalTextRange: event.originalTextRange
538
+ ? { ...event.originalTextRange }
539
+ : { ...originalTextRange },
540
+ })),
541
+ bookmarks: result.bookmarks?.map((event) => ({
542
+ ...event,
543
+ originalTextRange: event.originalTextRange
544
+ ? { ...event.originalTextRange }
545
+ : { ...originalTextRange },
546
+ })),
547
+ }
548
+ : {}),
549
+ };
550
+ completed += 1;
551
+ options.onProgress?.({
552
+ currentChunk: completed,
553
+ totalChunks: chunks.length,
554
+ percent: chunks.length === 0 ? 100 : Math.round((completed / chunks.length) * 100),
555
+ chunkIndex: index,
556
+ originalTextRange: input.originalTextRange,
557
+ status: "success",
558
+ durationMs: Date.now() - startedAt,
559
+ });
560
+ } catch (error) {
561
+ failedIndices.add(index);
562
+ options.onProgress?.({
563
+ currentChunk: completed,
564
+ totalChunks: chunks.length,
565
+ percent: chunks.length === 0 ? 100 : Math.round((index / chunks.length) * 100),
566
+ chunkIndex: index,
567
+ originalTextRange: input.originalTextRange,
568
+ status: "failed",
569
+ durationMs: Date.now() - startedAt,
570
+ error,
571
+ });
572
+ if (options.cancelOnFailure !== false) jobScope?.abort();
573
+ firstError ??= error;
574
+ return;
575
+ }
299
576
  }
577
+ };
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;
300
589
  }
590
+ const orderedResults = results.filter((result): result is SsmlSynthesisResult => result !== undefined);
301
591
  return {
302
592
  ok: true,
303
593
  success: true,
304
594
  status: "success",
305
- value: mergeSynthesisResults(results, {
595
+ value: await mergeSynthesisResults(orderedResults, {
306
596
  format: (options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3") as AzureTtsOutputFormat,
597
+ signal: jobScope?.signal ?? options.signal,
598
+ customMerger: options.customMerger,
599
+ outputMimeType: options.outputMimeType,
600
+ postMergeValidator: options.postMergeValidator,
307
601
  }),
308
602
  };
309
603
  } catch (error) {
310
604
  const synthesisError = toSynthesisError(error);
311
- return failure(synthesisError);
605
+ return failure(synthesisError, partialResultFrom(error));
606
+ } finally {
607
+ fallbackJobScope?.dispose();
312
608
  }
313
609
  }
314
610