@ssml-builder-js/azure-tts-client 2.18.0 → 2.19.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.18.0",
3
+ "version": "2.19.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.18.0",
38
+ "@ssml-builder-js/ssml-core": "^2.19.0",
39
39
  "microsoft-cognitiveservices-speech-sdk": "1.51.0"
40
40
  }
41
41
  }
package/src/client.ts CHANGED
@@ -19,7 +19,16 @@ export class AzureTtsClient {
19
19
  }
20
20
 
21
21
  async synthesize(ssml: string): Promise<ArrayBuffer> {
22
- const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = this.#options;
22
+ const {
23
+ region,
24
+ subscriptionKey,
25
+ outputFormat,
26
+ signal,
27
+ timeoutMs,
28
+ timeouts,
29
+ customHeaders,
30
+ fingerprintSchemaVersion,
31
+ } = this.#options;
23
32
  const endpoint = this.#options.endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
24
33
  this.#options.logger?.debug?.("Using Azure TTS endpoint:", endpoint);
25
34
 
@@ -32,12 +41,23 @@ export class AzureTtsClient {
32
41
  timeoutMs,
33
42
  timeouts,
34
43
  retryOptions: this.#options.retryOptions,
44
+ customHeaders,
45
+ fingerprintSchemaVersion,
35
46
  };
36
47
  return synthesizeSpeech(ssml, config);
37
48
  }
38
49
 
39
50
  async synthesizeSsml(ssml: string, options: Partial<TtsConfig> = {}): Promise<SsmlSynthesisResult> {
40
- const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = this.#options;
51
+ const {
52
+ region,
53
+ subscriptionKey,
54
+ outputFormat,
55
+ signal,
56
+ timeoutMs,
57
+ timeouts,
58
+ customHeaders,
59
+ fingerprintSchemaVersion,
60
+ } = this.#options;
41
61
  const endpoint = this.#options.endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
42
62
  this.#options.logger?.debug?.("Using Azure TTS endpoint:", endpoint);
43
63
 
@@ -58,6 +78,8 @@ export class AzureTtsClient {
58
78
  outputMimeType: options.outputMimeType ?? this.#options.outputMimeType,
59
79
  postMergeValidator: options.postMergeValidator ?? this.#options.postMergeValidator,
60
80
  resumeValidation: options.resumeValidation ?? this.#options.resumeValidation,
81
+ customHeaders: options.customHeaders ?? customHeaders,
82
+ fingerprintSchemaVersion: options.fingerprintSchemaVersion ?? fingerprintSchemaVersion,
61
83
  });
62
84
  }
63
85
 
@@ -65,7 +87,16 @@ export class AzureTtsClient {
65
87
  chunks: readonly (SsmlSynthesisChunk | string)[],
66
88
  options: SynthesizeChunksOptions = {},
67
89
  ): Promise<SsmlSynthesisResult> {
68
- const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = this.#options;
90
+ const {
91
+ region,
92
+ subscriptionKey,
93
+ outputFormat,
94
+ signal,
95
+ timeoutMs,
96
+ timeouts,
97
+ customHeaders,
98
+ fingerprintSchemaVersion,
99
+ } = this.#options;
69
100
  const endpoint = this.#options.endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
70
101
  return synthesizeSsmlChunks(chunks, {
71
102
  endpoint,
@@ -86,6 +117,8 @@ export class AzureTtsClient {
86
117
  outputMimeType: options.outputMimeType ?? this.#options.outputMimeType,
87
118
  postMergeValidator: options.postMergeValidator ?? this.#options.postMergeValidator,
88
119
  resumeValidation: options.resumeValidation ?? this.#options.resumeValidation,
120
+ customHeaders: options.customHeaders ?? customHeaders,
121
+ fingerprintSchemaVersion: options.fingerprintSchemaVersion ?? fingerprintSchemaVersion,
89
122
  });
90
123
  }
91
124
 
@@ -106,6 +139,13 @@ export class AzureTtsClient {
106
139
  onProgress: options.onProgress ?? this.#options.onProgress,
107
140
  concurrency: options.concurrency ?? this.#options.concurrency,
108
141
  retryOptions: options.retryOptions ?? this.#options.retryOptions,
142
+ cancelOnFailure: options.cancelOnFailure ?? this.#options.cancelOnFailure,
143
+ customMerger: options.customMerger ?? this.#options.customMerger,
144
+ outputMimeType: options.outputMimeType ?? this.#options.outputMimeType,
145
+ postMergeValidator: options.postMergeValidator ?? this.#options.postMergeValidator,
146
+ resumeValidation: options.resumeValidation ?? this.#options.resumeValidation,
147
+ customHeaders: options.customHeaders ?? this.#options.customHeaders,
148
+ fingerprintSchemaVersion: options.fingerprintSchemaVersion ?? this.#options.fingerprintSchemaVersion,
109
149
  });
110
150
  }
111
151
 
@@ -0,0 +1,52 @@
1
+ import { SynthesisTimeoutError } from "./errors.ts";
2
+
3
+ /** Coordinates one absolute deadline across validation, synthesis, retries, and merging. */
4
+ export class DeadlineController {
5
+ readonly deadlineAtMs: number | undefined;
6
+ readonly signal: AbortSignal;
7
+ readonly #controller = new AbortController();
8
+ readonly #parent?: AbortSignal;
9
+ readonly #onParentAbort: () => void;
10
+ #timer: ReturnType<typeof setTimeout> | undefined;
11
+ #timedOut = false;
12
+
13
+ constructor(totalJobMs: number | undefined, parent?: AbortSignal) {
14
+ this.#parent = parent;
15
+ this.#onParentAbort = () => this.#controller.abort();
16
+ this.deadlineAtMs = totalJobMs !== undefined && totalJobMs > 0 ? Date.now() + totalJobMs : undefined;
17
+ this.signal = this.deadlineAtMs === undefined && parent ? parent : this.#controller.signal;
18
+ if (parent?.aborted) this.#controller.abort();
19
+ parent?.addEventListener("abort", this.#onParentAbort, { once: true });
20
+ if (this.deadlineAtMs !== undefined) {
21
+ this.#timer = setTimeout(
22
+ () => {
23
+ this.#timedOut = true;
24
+ this.#controller.abort();
25
+ },
26
+ Math.max(0, this.deadlineAtMs - Date.now()),
27
+ );
28
+ }
29
+ }
30
+
31
+ get timedOut(): boolean {
32
+ return this.#timedOut || (this.deadlineAtMs !== undefined && this.remainingMs <= 0);
33
+ }
34
+
35
+ get remainingMs(): number {
36
+ return this.deadlineAtMs === undefined ? Number.POSITIVE_INFINITY : Math.max(0, this.deadlineAtMs - Date.now());
37
+ }
38
+
39
+ throwIfExpired(): void {
40
+ if (this.timedOut) throw new SynthesisTimeoutError("Speech synthesis exceeded the total job deadline.");
41
+ if (this.signal.aborted) throw new Error("Speech synthesis was cancelled.");
42
+ }
43
+
44
+ abort(): void {
45
+ this.#controller.abort();
46
+ }
47
+
48
+ dispose(): void {
49
+ if (this.#timer) clearTimeout(this.#timer);
50
+ this.#parent?.removeEventListener("abort", this.#onParentAbort);
51
+ }
52
+ }
package/src/errors.ts CHANGED
@@ -5,7 +5,29 @@ export type SynthesisErrorKind =
5
5
  | "audio-format-mismatch"
6
6
  | "unsupported-format-error"
7
7
  | "cancelled"
8
- | "timeout";
8
+ | "timeout"
9
+ | "incomplete-chunk-set";
10
+
11
+ export type SerializedChunkErrorCode =
12
+ | "VALIDATION_ERROR"
13
+ | "AZURE_API_ERROR"
14
+ | "TIMEOUT"
15
+ | "CANCELLED"
16
+ | "MERGE_ERROR"
17
+ | "FORMAT_MISMATCH";
18
+
19
+ export interface SerializedChunkError {
20
+ code: SerializedChunkErrorCode;
21
+ phase: "url-validation" | "synthesis" | "merge";
22
+ message: string;
23
+ isOriginalFailure: boolean;
24
+ isRetryable: boolean;
25
+ httpStatus?: number;
26
+ details?: Record<string, unknown>;
27
+ }
28
+
29
+ /** Backward-compatible name used by persisted execution-state consumers. */
30
+ export type SerializedExecutionError = SerializedChunkError;
9
31
 
10
32
  export class AzureTtsError extends Error {
11
33
  readonly kind = "azure-api-error" as const;
@@ -95,6 +117,19 @@ export class SynthesisTimeoutError extends Error {
95
117
  }
96
118
  }
97
119
 
120
+ export class IncompleteChunkSetError extends Error {
121
+ readonly kind = "incomplete-chunk-set" as const;
122
+ readonly totalChunks: number;
123
+ readonly missingChunkIndices: readonly number[];
124
+
125
+ constructor(totalChunks: number, missingChunkIndices: readonly number[]) {
126
+ super(`Cannot merge an incomplete chunk set; missing chunk indices: ${missingChunkIndices.join(", ")}.`);
127
+ this.name = "IncompleteChunkSetError";
128
+ this.totalChunks = totalChunks;
129
+ this.missingChunkIndices = [...missingChunkIndices];
130
+ }
131
+ }
132
+
98
133
  export class MergeError extends Error {
99
134
  readonly kind = "merge-error" as const;
100
135
  readonly cause: unknown;
@@ -136,7 +171,52 @@ export type AzureTtsSynthesisError =
136
171
  | AudioFormatMismatchError
137
172
  | UnsupportedMergeFormatError
138
173
  | SynthesisCancelledError
139
- | SynthesisTimeoutError;
174
+ | SynthesisTimeoutError
175
+ | IncompleteChunkSetError;
176
+
177
+ export function serializeChunkError(
178
+ error: unknown,
179
+ phase: SerializedChunkError["phase"],
180
+ isOriginalFailure: boolean,
181
+ ): SerializedChunkError {
182
+ const message = error instanceof Error ? error.message : String(error);
183
+ const status = error instanceof AzureTtsError ? error.status : undefined;
184
+ const kind = error && typeof error === "object" && "kind" in error ? String(error.kind) : "";
185
+ const code: SerializedChunkErrorCode =
186
+ kind === "validation-error"
187
+ ? "VALIDATION_ERROR"
188
+ : kind === "timeout" || /tim(?:e|ed) ?out|deadline/i.test(message)
189
+ ? "TIMEOUT"
190
+ : kind === "cancelled" || /cancel|abort/i.test(message)
191
+ ? "CANCELLED"
192
+ : kind === "audio-format-mismatch" || kind === "unsupported-format-error"
193
+ ? "FORMAT_MISMATCH"
194
+ : kind === "merge-error" || phase === "merge"
195
+ ? "MERGE_ERROR"
196
+ : "AZURE_API_ERROR";
197
+ const details: Record<string, unknown> = {};
198
+ if (error instanceof AzureTtsError) {
199
+ details.statusText = error.statusText;
200
+ if (error.requestId) details.requestId = error.requestId;
201
+ }
202
+ if (error instanceof IncompleteChunkSetError) {
203
+ details.totalChunks = error.totalChunks;
204
+ details.missingChunkIndices = [...error.missingChunkIndices];
205
+ }
206
+ return {
207
+ code,
208
+ phase,
209
+ message,
210
+ isOriginalFailure,
211
+ isRetryable:
212
+ code === "AZURE_API_ERROR" &&
213
+ (status === 429 ||
214
+ (status !== undefined && status >= 500) ||
215
+ /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message)),
216
+ ...(status !== undefined && status > 0 ? { httpStatus: status } : {}),
217
+ ...(Object.keys(details).length > 0 ? { details } : {}),
218
+ };
219
+ }
140
220
 
141
221
  export function toSynthesisError(
142
222
  error: unknown,
@@ -146,14 +226,16 @@ export function toSynthesisError(
146
226
  | AudioFormatMismatchError
147
227
  | UnsupportedMergeFormatError
148
228
  | SynthesisCancelledError
149
- | SynthesisTimeoutError {
229
+ | SynthesisTimeoutError
230
+ | IncompleteChunkSetError {
150
231
  if (
151
232
  error instanceof AzureTtsError ||
152
233
  error instanceof MergeError ||
153
234
  error instanceof AudioFormatMismatchError ||
154
235
  error instanceof UnsupportedMergeFormatError ||
155
236
  error instanceof SynthesisCancelledError ||
156
- error instanceof SynthesisTimeoutError
237
+ error instanceof SynthesisTimeoutError ||
238
+ error instanceof IncompleteChunkSetError
157
239
  )
158
240
  return error;
159
241
  const message = error instanceof Error ? error.message : String(error);
package/src/index.ts CHANGED
@@ -36,10 +36,19 @@ export {
36
36
  MergeError,
37
37
  SynthesisCancelledError,
38
38
  SynthesisTimeoutError,
39
+ IncompleteChunkSetError,
40
+ serializeChunkError,
39
41
  UnsupportedMergeFormatError,
40
42
  getRetryAfterDelayMs,
41
43
  } from "./errors.ts";
42
- export type { AzureTtsSynthesisError, SynthesisErrorKind } from "./errors.ts";
44
+ export type {
45
+ AzureTtsSynthesisError,
46
+ SerializedChunkError,
47
+ SerializedChunkErrorCode,
48
+ SerializedExecutionError,
49
+ SynthesisErrorKind,
50
+ } from "./errors.ts";
51
+ export { DeadlineController } from "./deadline.ts";
43
52
  export { AzureTtsClient } from "./client.ts";
44
53
  export { synthesizeSpeech } from "./synthesis.ts";
45
54
  export { synthesizeSsml } from "./synthesis.ts";
@@ -54,6 +63,7 @@ export {
54
63
  } from "./synthesis.ts";
55
64
  export type {
56
65
  InputAudioSpecs,
66
+ ChunkFingerprintOptions,
57
67
  MergeAudioFormat,
58
68
  MergeAudioOptions,
59
69
  MergeSynthesisOptions,
package/src/safe.ts CHANGED
@@ -29,6 +29,8 @@ import type {
29
29
  ResumeValidationMode,
30
30
  } from "./types.ts";
31
31
  import type { AzureTtsOutputFormat } from "./outputFormats.ts";
32
+ import { IncompleteChunkSetError, serializeChunkError } from "./errors.ts";
33
+ import { DeadlineController } from "./deadline.ts";
32
34
 
33
35
  export interface SsmlValidationError {
34
36
  readonly kind: "validation-error";
@@ -129,6 +131,8 @@ export interface SynthesizeSsmlSafeOptions extends AzureValidationOptions {
129
131
  export interface SynthesizeSsmlChunksSafeOptions extends AzureValidationOptions {
130
132
  validation?: AzureValidationOptions;
131
133
  outputFormat?: string;
134
+ customHeaders?: Readonly<Record<string, string>>;
135
+ fingerprintSchemaVersion?: string;
132
136
  signal?: AbortSignal;
133
137
  timeoutMs?: number;
134
138
  timeouts?: SynthesisTimeouts;
@@ -303,14 +307,19 @@ export async function synthesizeSsmlSafe(
303
307
  ssml: string,
304
308
  options: SynthesizeSsmlSafeOptions = {},
305
309
  ): Promise<SsmlSynthesisSafeResult> {
306
- const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
310
+ const deadline = new DeadlineController(options.timeouts?.totalJobMs, options.signal);
311
+ const validationOptions = sharedValidationOptions(options.validation ?? options, deadline.signal);
307
312
  const diagnostics = await Promise.resolve(validateAzureSsml(ssml, validationOptions));
308
- if (options.signal?.aborted) {
309
- const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
313
+ if (deadline.signal.aborted) {
314
+ const error = toSynthesisError(
315
+ new Error(deadline.timedOut ? "Speech synthesis timed out." : "Speech synthesis was cancelled."),
316
+ );
317
+ deadline.dispose();
310
318
  return failure(error);
311
319
  }
312
320
  const errors = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
313
321
  if (errors.length > 0) {
322
+ deadline.dispose();
314
323
  return failure({
315
324
  kind: "validation-error",
316
325
  message: "SSML validation failed; the Azure Speech API was not called.",
@@ -318,27 +327,23 @@ export async function synthesizeSsmlSafe(
318
327
  });
319
328
  }
320
329
 
321
- const jobScope =
322
- options.timeouts?.totalJobMs !== undefined
323
- ? createSafeAbortScope(options.signal, options.timeouts.totalJobMs)
324
- : undefined;
325
330
  try {
326
331
  return {
327
332
  ok: true,
328
333
  success: true,
329
334
  status: "success",
330
335
  value: await client.synthesizeSsml(ssml, {
331
- signal: jobScope?.signal ?? options.signal,
336
+ signal: deadline.signal,
332
337
  timeoutMs: options.timeouts?.perChunkMs,
333
- timeouts: options.timeouts,
338
+ timeouts: options.timeouts ? { ...options.timeouts, totalJobMs: undefined } : undefined,
334
339
  }),
335
340
  };
336
341
  } catch (error) {
337
- if (jobScope?.timedOut()) return failure(toSynthesisError(new Error("Speech synthesis timed out.")));
342
+ if (deadline.timedOut) return failure(toSynthesisError(new Error("Speech synthesis timed out.")));
338
343
  const synthesisError = toSynthesisError(error);
339
344
  return failure(synthesisError);
340
345
  } finally {
341
- jobScope?.dispose();
346
+ deadline.dispose();
342
347
  }
343
348
  }
344
349
 
@@ -348,12 +353,16 @@ export async function synthesizeSsmlChunksSafe(
348
353
  chunks: readonly (SsmlSynthesisChunk | string)[],
349
354
  options: SynthesizeSsmlChunksSafeOptions = {},
350
355
  ): Promise<SsmlSynthesisChunksSafeResult> {
356
+ const deadline = new DeadlineController(options.timeouts?.totalJobMs, options.signal);
351
357
  const validationOptions = sharedValidationOptions(
352
358
  { ...(options.validation ?? options), timeouts: options.timeouts },
353
- options.signal,
359
+ deadline.signal,
354
360
  );
355
- if (options.signal?.aborted) {
356
- const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
361
+ if (deadline.signal.aborted) {
362
+ const error = toSynthesisError(
363
+ new Error(deadline.timedOut ? "Speech synthesis timed out." : "Speech synthesis was cancelled."),
364
+ );
365
+ deadline.dispose();
357
366
  return failure(error);
358
367
  }
359
368
  const pending = (index: number, status: SynthesisProgressEvent["status"], error?: unknown): void => {
@@ -387,8 +396,9 @@ export async function synthesizeSsmlChunksSafe(
387
396
  return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
388
397
  }),
389
398
  );
390
- if (options.signal?.aborted) {
399
+ if (deadline.signal.aborted) {
391
400
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
401
+ deadline.dispose();
392
402
  return failure(error);
393
403
  }
394
404
  const chunkDiagnostics = validations
@@ -397,6 +407,7 @@ export async function synthesizeSsmlChunksSafe(
397
407
  if (chunkDiagnostics.length > 0) {
398
408
  const error = new BatchChunkValidationError(chunkDiagnostics);
399
409
  for (const entry of chunkDiagnostics) pending(entry.chunkIndex, "failed", error);
410
+ deadline.dispose();
400
411
  return failure(error);
401
412
  }
402
413
 
@@ -410,9 +421,9 @@ export async function synthesizeSsmlChunksSafe(
410
421
  const value = await client.synthesizeChunks(normalizedChunks, {
411
422
  onProgress: options.onProgress,
412
423
  outputFormat: options.outputFormat,
413
- signal: options.signal,
424
+ signal: deadline.signal,
414
425
  timeoutMs: options.timeoutMs,
415
- timeouts: options.timeouts,
426
+ timeouts: options.timeouts ? { ...options.timeouts, totalJobMs: undefined } : undefined,
416
427
  sourceNodePath: options.sourceNodePath,
417
428
  concurrency: options.concurrency,
418
429
  retryOptions: options.retryOptions,
@@ -423,11 +434,18 @@ export async function synthesizeSsmlChunksSafe(
423
434
  outputMimeType: options.outputMimeType,
424
435
  postMergeValidator: options.postMergeValidator,
425
436
  resumeValidation: options.resumeValidation,
437
+ customHeaders: options.customHeaders,
438
+ fingerprintSchemaVersion: options.fingerprintSchemaVersion,
426
439
  });
427
440
  return { ok: true, success: true, status: "success", value };
428
441
  }
429
442
  const inputs = chunks.map((chunk) => (typeof chunk === "string" ? { ssml: chunk } : chunk));
430
- const fingerprints = inputs.map((chunk) => computeChunkFingerprint(chunk.ssml, options.outputFormat));
443
+ const fingerprints = inputs.map((chunk) =>
444
+ computeChunkFingerprint(chunk.ssml, options.outputFormat, {
445
+ customHeaders: options.customHeaders,
446
+ fingerprintSchemaVersion: options.fingerprintSchemaVersion,
447
+ }),
448
+ );
431
449
  const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
432
450
  const chunkStates: ChunkExecutionState[] = inputs.map((_chunk, chunkIndex) => ({
433
451
  chunkIndex,
@@ -449,14 +467,10 @@ export async function synthesizeSsmlChunksSafe(
449
467
  const shouldSynthesize = (index: number): boolean =>
450
468
  (!cachedChunks.has(index) || invalidCachedIndices.has(index)) &&
451
469
  (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;
470
+ const jobDeadlineAt = deadline.deadlineAtMs;
457
471
  const jobScope =
458
472
  chunks.length > 1 || options.timeouts?.totalJobMs !== undefined
459
- ? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs)
473
+ ? createSafeAbortScope(deadline.signal, undefined)
460
474
  : undefined;
461
475
  fallbackJobScope = jobScope;
462
476
  const failedIndices = new Set<number>();
@@ -483,9 +497,9 @@ export async function synthesizeSsmlChunksSafe(
483
497
  const chunkTimeout = options.timeouts?.chunkWithRetriesMs ?? options.timeouts?.perChunkMs;
484
498
  const chunkScope =
485
499
  chunkTimeout !== undefined || jobScope
486
- ? createSafeAbortScope(jobScope?.signal ?? options.signal, chunkTimeout ?? options.timeoutMs)
500
+ ? createSafeAbortScope(jobScope?.signal ?? deadline.signal, chunkTimeout ?? options.timeoutMs)
487
501
  : undefined;
488
- const chunkSignal = chunkScope?.signal ?? options.signal;
502
+ const chunkSignal = chunkScope?.signal ?? deadline.signal;
489
503
  let result: SsmlSynthesisResult;
490
504
  try {
491
505
  result = await retryableSynthesis(
@@ -514,7 +528,7 @@ export async function synthesizeSsmlChunksSafe(
514
528
  jobDeadlineAt,
515
529
  );
516
530
  } catch (error) {
517
- if (chunkScope?.timedOut())
531
+ if (chunkScope?.timedOut() || deadline.timedOut)
518
532
  throw new Error(`Speech synthesis timed out after ${chunkTimeout ?? options.timeoutMs} ms.`);
519
533
  throw error;
520
534
  } finally {
@@ -601,15 +615,17 @@ export async function synthesizeSsmlChunksSafe(
601
615
  durationMs: Date.now() - startedAt,
602
616
  });
603
617
  } catch (error) {
604
- const wasCancelled = firstError !== undefined || Boolean(jobScope?.signal.aborted && !jobScope?.timedOut());
605
- firstError ??= error;
618
+ const wasCancelled =
619
+ firstError !== undefined ||
620
+ (!deadline.timedOut && Boolean(jobScope?.signal.aborted && !jobScope?.timedOut()));
621
+ firstError ??= deadline.timedOut ? new Error("Speech synthesis timed out.") : error;
606
622
  if (!wasCancelled) failedIndices.add(index);
607
623
  chunkStates[index] = {
608
624
  chunkIndex: index,
609
625
  status: wasCancelled ? "cancelled" : "failed",
610
626
  isOriginalFailure: !wasCancelled,
611
627
  canResume: true,
612
- error: error as ChunkExecutionState["error"],
628
+ error: serializeChunkError(error, "synthesis", !wasCancelled),
613
629
  };
614
630
  options.onProgress?.({
615
631
  currentChunk: completed,
@@ -656,6 +672,10 @@ export async function synthesizeSsmlChunksSafe(
656
672
  };
657
673
  throw error;
658
674
  }
675
+ const missingChunkIndices = Array.from({ length: chunks.length }, (_value, index) =>
676
+ results[index] === undefined ? index : undefined,
677
+ ).filter((index): index is number => index !== undefined);
678
+ if (missingChunkIndices.length > 0) throw new IncompleteChunkSetError(chunks.length, missingChunkIndices);
659
679
  const orderedResults = results.filter((result): result is SsmlSynthesisResult => result !== undefined);
660
680
  return {
661
681
  ok: true,
@@ -663,7 +683,7 @@ export async function synthesizeSsmlChunksSafe(
663
683
  status: "success",
664
684
  value: await mergeSynthesisResults(orderedResults, {
665
685
  format: (options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3") as AzureTtsOutputFormat,
666
- signal: jobScope?.signal ?? options.signal,
686
+ signal: jobScope?.signal ?? deadline.signal,
667
687
  customMerger: options.customMerger,
668
688
  outputMimeType: options.outputMimeType,
669
689
  postMergeValidator: options.postMergeValidator,
@@ -674,6 +694,7 @@ export async function synthesizeSsmlChunksSafe(
674
694
  return failure(synthesisError, partialResultFrom(error));
675
695
  } finally {
676
696
  fallbackJobScope?.dispose();
697
+ deadline.dispose();
677
698
  }
678
699
  }
679
700