@ssml-builder-js/azure-tts-client 2.17.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/src/safe.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  getRetryAfterDelayMs,
14
14
  } from "./errors.ts";
15
15
  import type { AzureTtsClient } from "./client.ts";
16
- import { mergeSynthesisResults } from "./synthesis.ts";
16
+ import { computeChunkFingerprint, mergeSynthesisResults } from "./synthesis.ts";
17
17
  import type {
18
18
  SsmlSynthesisChunk,
19
19
  SsmlSynthesisResult,
@@ -25,8 +25,12 @@ import type {
25
25
  PartialChunkSynthesisResult,
26
26
  CustomAudioMerger,
27
27
  PostMergeValidator,
28
+ ChunkExecutionState,
29
+ ResumeValidationMode,
28
30
  } from "./types.ts";
29
31
  import type { AzureTtsOutputFormat } from "./outputFormats.ts";
32
+ import { IncompleteChunkSetError, serializeChunkError } from "./errors.ts";
33
+ import { DeadlineController } from "./deadline.ts";
30
34
 
31
35
  export interface SsmlValidationError {
32
36
  readonly kind: "validation-error";
@@ -127,6 +131,8 @@ export interface SynthesizeSsmlSafeOptions extends AzureValidationOptions {
127
131
  export interface SynthesizeSsmlChunksSafeOptions extends AzureValidationOptions {
128
132
  validation?: AzureValidationOptions;
129
133
  outputFormat?: string;
134
+ customHeaders?: Readonly<Record<string, string>>;
135
+ fingerprintSchemaVersion?: string;
130
136
  signal?: AbortSignal;
131
137
  timeoutMs?: number;
132
138
  timeouts?: SynthesisTimeouts;
@@ -140,6 +146,7 @@ export interface SynthesizeSsmlChunksSafeOptions extends AzureValidationOptions
140
146
  customMerger?: CustomAudioMerger;
141
147
  outputMimeType?: string;
142
148
  postMergeValidator?: PostMergeValidator;
149
+ resumeValidation?: ResumeValidationMode;
143
150
  }
144
151
 
145
152
  export type SsmlSynthesisChunksSafeResult =
@@ -225,6 +232,7 @@ async function retryableSynthesis(
225
232
  options: RetryOptions | undefined,
226
233
  signal: AbortSignal | undefined,
227
234
  onRetry: (attempt: number, delayMs: number) => void,
235
+ deadlineAtMs?: number,
228
236
  ): Promise<SsmlSynthesisResult> {
229
237
  const retry = options
230
238
  ? {
@@ -244,6 +252,14 @@ async function retryableSynthesis(
244
252
  throw error;
245
253
  attempt += 1;
246
254
  const delayMs = retryDelayForError(retry, attempt, error);
255
+ const retryAfterMs = getRetryAfterDelayMs(error);
256
+ const remainingMs = deadlineAtMs === undefined ? undefined : Math.max(0, deadlineAtMs - Date.now());
257
+ if (
258
+ retryAfterMs !== undefined &&
259
+ (retryAfterMs > retry.maxDelayMs || (remainingMs !== undefined && retryAfterMs > remainingMs))
260
+ ) {
261
+ throw new Error("Speech synthesis timed out because Retry-After exceeded the available retry budget.");
262
+ }
247
263
  onRetry(attempt, delayMs);
248
264
  if (delayMs > 0)
249
265
  await new Promise<void>((resolve, reject) => {
@@ -291,14 +307,19 @@ export async function synthesizeSsmlSafe(
291
307
  ssml: string,
292
308
  options: SynthesizeSsmlSafeOptions = {},
293
309
  ): Promise<SsmlSynthesisSafeResult> {
294
- 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);
295
312
  const diagnostics = await Promise.resolve(validateAzureSsml(ssml, validationOptions));
296
- if (options.signal?.aborted) {
297
- 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();
298
318
  return failure(error);
299
319
  }
300
320
  const errors = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
301
321
  if (errors.length > 0) {
322
+ deadline.dispose();
302
323
  return failure({
303
324
  kind: "validation-error",
304
325
  message: "SSML validation failed; the Azure Speech API was not called.",
@@ -312,14 +333,17 @@ export async function synthesizeSsmlSafe(
312
333
  success: true,
313
334
  status: "success",
314
335
  value: await client.synthesizeSsml(ssml, {
315
- signal: options.signal,
336
+ signal: deadline.signal,
316
337
  timeoutMs: options.timeouts?.perChunkMs,
317
- timeouts: options.timeouts,
338
+ timeouts: options.timeouts ? { ...options.timeouts, totalJobMs: undefined } : undefined,
318
339
  }),
319
340
  };
320
341
  } catch (error) {
342
+ if (deadline.timedOut) return failure(toSynthesisError(new Error("Speech synthesis timed out.")));
321
343
  const synthesisError = toSynthesisError(error);
322
344
  return failure(synthesisError);
345
+ } finally {
346
+ deadline.dispose();
323
347
  }
324
348
  }
325
349
 
@@ -329,12 +353,16 @@ export async function synthesizeSsmlChunksSafe(
329
353
  chunks: readonly (SsmlSynthesisChunk | string)[],
330
354
  options: SynthesizeSsmlChunksSafeOptions = {},
331
355
  ): Promise<SsmlSynthesisChunksSafeResult> {
356
+ const deadline = new DeadlineController(options.timeouts?.totalJobMs, options.signal);
332
357
  const validationOptions = sharedValidationOptions(
333
358
  { ...(options.validation ?? options), timeouts: options.timeouts },
334
- options.signal,
359
+ deadline.signal,
335
360
  );
336
- if (options.signal?.aborted) {
337
- 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();
338
366
  return failure(error);
339
367
  }
340
368
  const pending = (index: number, status: SynthesisProgressEvent["status"], error?: unknown): void => {
@@ -368,8 +396,9 @@ export async function synthesizeSsmlChunksSafe(
368
396
  return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
369
397
  }),
370
398
  );
371
- if (options.signal?.aborted) {
399
+ if (deadline.signal.aborted) {
372
400
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
401
+ deadline.dispose();
373
402
  return failure(error);
374
403
  }
375
404
  const chunkDiagnostics = validations
@@ -378,6 +407,7 @@ export async function synthesizeSsmlChunksSafe(
378
407
  if (chunkDiagnostics.length > 0) {
379
408
  const error = new BatchChunkValidationError(chunkDiagnostics);
380
409
  for (const entry of chunkDiagnostics) pending(entry.chunkIndex, "failed", error);
410
+ deadline.dispose();
381
411
  return failure(error);
382
412
  }
383
413
 
@@ -391,9 +421,9 @@ export async function synthesizeSsmlChunksSafe(
391
421
  const value = await client.synthesizeChunks(normalizedChunks, {
392
422
  onProgress: options.onProgress,
393
423
  outputFormat: options.outputFormat,
394
- signal: options.signal,
424
+ signal: deadline.signal,
395
425
  timeoutMs: options.timeoutMs,
396
- timeouts: options.timeouts,
426
+ timeouts: options.timeouts ? { ...options.timeouts, totalJobMs: undefined } : undefined,
397
427
  sourceNodePath: options.sourceNodePath,
398
428
  concurrency: options.concurrency,
399
429
  retryOptions: options.retryOptions,
@@ -403,22 +433,44 @@ export async function synthesizeSsmlChunksSafe(
403
433
  customMerger: options.customMerger,
404
434
  outputMimeType: options.outputMimeType,
405
435
  postMergeValidator: options.postMergeValidator,
436
+ resumeValidation: options.resumeValidation,
437
+ customHeaders: options.customHeaders,
438
+ fingerprintSchemaVersion: options.fingerprintSchemaVersion,
406
439
  });
407
440
  return { ok: true, success: true, status: "success", value };
408
441
  }
442
+ const inputs = chunks.map((chunk) => (typeof chunk === "string" ? { ssml: chunk } : chunk));
443
+ const fingerprints = inputs.map((chunk) =>
444
+ computeChunkFingerprint(chunk.ssml, options.outputFormat, {
445
+ customHeaders: options.customHeaders,
446
+ fingerprintSchemaVersion: options.fingerprintSchemaVersion,
447
+ }),
448
+ );
409
449
  const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
450
+ const chunkStates: ChunkExecutionState[] = inputs.map((_chunk, chunkIndex) => ({
451
+ chunkIndex,
452
+ status: "pending",
453
+ canResume: true,
454
+ }));
410
455
  const cachedChunks = new Map((options.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
456
+ const invalidCachedIndices = new Set<number>();
411
457
  for (const [index, cached] of cachedChunks) {
412
- if (index >= 0 && index < chunks.length) results[index] = cached;
458
+ if (index < 0 || index >= chunks.length) continue;
459
+ if (options.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index]) {
460
+ results[index] = cached;
461
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: cached };
462
+ } else invalidCachedIndices.add(index);
413
463
  }
414
464
  const requestedIndices = options.resumeChunkIndices
415
465
  ? new Set(options.resumeChunkIndices.filter((index) => index >= 0 && index < chunks.length))
416
466
  : undefined;
417
467
  const shouldSynthesize = (index: number): boolean =>
418
- !cachedChunks.has(index) && (requestedIndices === undefined || requestedIndices.has(index));
468
+ (!cachedChunks.has(index) || invalidCachedIndices.has(index)) &&
469
+ (requestedIndices === undefined || requestedIndices.has(index) || invalidCachedIndices.has(index));
470
+ const jobDeadlineAt = deadline.deadlineAtMs;
419
471
  const jobScope =
420
472
  chunks.length > 1 || options.timeouts?.totalJobMs !== undefined
421
- ? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs)
473
+ ? createSafeAbortScope(deadline.signal, undefined)
422
474
  : undefined;
423
475
  fallbackJobScope = jobScope;
424
476
  const failedIndices = new Set<number>();
@@ -431,7 +483,10 @@ export async function synthesizeSsmlChunksSafe(
431
483
  const index = nextIndex++;
432
484
  if (index >= chunks.length) return;
433
485
  if (!shouldSynthesize(index)) continue;
434
- if (failedIndices.size > 0 && options.cancelOnFailure !== false) return;
486
+ if (firstError && options.cancelOnFailure !== false) {
487
+ chunkStates[index] = { chunkIndex: index, status: "cancelled", isOriginalFailure: false, canResume: true };
488
+ return;
489
+ }
435
490
  const chunk = chunks[index];
436
491
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
437
492
  const sourceNodePath = input.sourceNodePath;
@@ -442,9 +497,9 @@ export async function synthesizeSsmlChunksSafe(
442
497
  const chunkTimeout = options.timeouts?.chunkWithRetriesMs ?? options.timeouts?.perChunkMs;
443
498
  const chunkScope =
444
499
  chunkTimeout !== undefined || jobScope
445
- ? createSafeAbortScope(jobScope?.signal ?? options.signal, chunkTimeout ?? options.timeoutMs)
500
+ ? createSafeAbortScope(jobScope?.signal ?? deadline.signal, chunkTimeout ?? options.timeoutMs)
446
501
  : undefined;
447
- const chunkSignal = chunkScope?.signal ?? options.signal;
502
+ const chunkSignal = chunkScope?.signal ?? deadline.signal;
448
503
  let result: SsmlSynthesisResult;
449
504
  try {
450
505
  result = await retryableSynthesis(
@@ -470,9 +525,10 @@ export async function synthesizeSsmlChunksSafe(
470
525
  nextRetryDelayMs,
471
526
  isRetrying: true,
472
527
  }),
528
+ jobDeadlineAt,
473
529
  );
474
530
  } catch (error) {
475
- if (chunkScope?.timedOut())
531
+ if (chunkScope?.timedOut() || deadline.timedOut)
476
532
  throw new Error(`Speech synthesis timed out after ${chunkTimeout ?? options.timeoutMs} ms.`);
477
533
  throw error;
478
534
  } finally {
@@ -547,6 +603,7 @@ export async function synthesizeSsmlChunksSafe(
547
603
  }
548
604
  : {}),
549
605
  };
606
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
550
607
  completed += 1;
551
608
  options.onProgress?.({
552
609
  currentChunk: completed,
@@ -558,7 +615,18 @@ export async function synthesizeSsmlChunksSafe(
558
615
  durationMs: Date.now() - startedAt,
559
616
  });
560
617
  } catch (error) {
561
- failedIndices.add(index);
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;
622
+ if (!wasCancelled) failedIndices.add(index);
623
+ chunkStates[index] = {
624
+ chunkIndex: index,
625
+ status: wasCancelled ? "cancelled" : "failed",
626
+ isOriginalFailure: !wasCancelled,
627
+ canResume: true,
628
+ error: serializeChunkError(error, "synthesis", !wasCancelled),
629
+ };
562
630
  options.onProgress?.({
563
631
  currentChunk: completed,
564
632
  totalChunks: chunks.length,
@@ -570,23 +638,44 @@ export async function synthesizeSsmlChunksSafe(
570
638
  error,
571
639
  });
572
640
  if (options.cancelOnFailure !== false) jobScope?.abort();
573
- firstError ??= error;
574
641
  return;
575
642
  }
576
643
  }
577
644
  };
578
645
  await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
646
+ if (firstError && options.cancelOnFailure !== false) {
647
+ for (const [chunkIndex, state] of chunkStates.entries()) {
648
+ if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
649
+ chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
650
+ }
651
+ }
652
+ }
579
653
  if (failedIndices.size > 0) {
580
654
  const error = firstError ?? new Error("One or more SSML chunks failed to synthesize.");
655
+ const synthesizedChunks = results.flatMap((result, chunkIndex) =>
656
+ result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : [],
657
+ );
581
658
  (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])),
659
+ synthesizedChunks,
660
+ completedChunks: synthesizedChunks,
661
+ pendingChunkIndices: chunkStates.flatMap((state) =>
662
+ state.status === "pending" || state.status === "cancelled" || state.status === "failed"
663
+ ? [state.chunkIndex]
664
+ : [],
665
+ ),
585
666
  failedChunkIndices: [...failedIndices],
667
+ cancelledChunkIndices: chunkStates
668
+ .filter((state) => state.status === "cancelled")
669
+ .map((state) => state.chunkIndex),
670
+ chunkStates,
586
671
  totalChunks: chunks.length,
587
672
  };
588
673
  throw error;
589
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);
590
679
  const orderedResults = results.filter((result): result is SsmlSynthesisResult => result !== undefined);
591
680
  return {
592
681
  ok: true,
@@ -594,7 +683,7 @@ export async function synthesizeSsmlChunksSafe(
594
683
  status: "success",
595
684
  value: await mergeSynthesisResults(orderedResults, {
596
685
  format: (options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3") as AzureTtsOutputFormat,
597
- signal: jobScope?.signal ?? options.signal,
686
+ signal: jobScope?.signal ?? deadline.signal,
598
687
  customMerger: options.customMerger,
599
688
  outputMimeType: options.outputMimeType,
600
689
  postMergeValidator: options.postMergeValidator,
@@ -605,6 +694,7 @@ export async function synthesizeSsmlChunksSafe(
605
694
  return failure(synthesisError, partialResultFrom(error));
606
695
  } finally {
607
696
  fallbackJobScope?.dispose();
697
+ deadline.dispose();
608
698
  }
609
699
  }
610
700