@ssml-builder-js/azure-tts-client 2.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssml-builder-js/azure-tts-client",
3
- "version": "2.17.0",
3
+ "version": "2.18.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.17.0",
38
+ "@ssml-builder-js/ssml-core": "^2.18.0",
39
39
  "microsoft-cognitiveservices-speech-sdk": "1.51.0"
40
40
  }
41
41
  }
package/src/client.ts CHANGED
@@ -23,7 +23,16 @@ export class AzureTtsClient {
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, timeouts };
26
+ const config = {
27
+ endpoint,
28
+ region,
29
+ subscriptionKey,
30
+ outputFormat,
31
+ signal,
32
+ timeoutMs,
33
+ timeouts,
34
+ retryOptions: this.#options.retryOptions,
35
+ };
27
36
  return synthesizeSpeech(ssml, config);
28
37
  }
29
38
 
@@ -43,6 +52,12 @@ export class AzureTtsClient {
43
52
  sourceNodePath: options.sourceNodePath,
44
53
  sourceTextSegments: options.sourceTextSegments,
45
54
  sourceMarkers: options.sourceMarkers,
55
+ retryOptions: options.retryOptions ?? this.#options.retryOptions,
56
+ cancelOnFailure: options.cancelOnFailure ?? this.#options.cancelOnFailure,
57
+ customMerger: options.customMerger ?? this.#options.customMerger,
58
+ outputMimeType: options.outputMimeType ?? this.#options.outputMimeType,
59
+ postMergeValidator: options.postMergeValidator ?? this.#options.postMergeValidator,
60
+ resumeValidation: options.resumeValidation ?? this.#options.resumeValidation,
46
61
  });
47
62
  }
48
63
 
@@ -64,12 +79,13 @@ export class AzureTtsClient {
64
79
  onProgress: options.onProgress ?? this.#options.onProgress,
65
80
  concurrency: options.concurrency ?? this.#options.concurrency,
66
81
  retryOptions: options.retryOptions ?? this.#options.retryOptions,
67
- cancelOnFailure: options.cancelOnFailure,
82
+ cancelOnFailure: options.cancelOnFailure ?? this.#options.cancelOnFailure,
68
83
  resumeChunks: options.resumeChunks,
69
84
  resumeChunkIndices: options.resumeChunkIndices,
70
- customMerger: options.customMerger,
71
- outputMimeType: options.outputMimeType,
72
- postMergeValidator: options.postMergeValidator,
85
+ customMerger: options.customMerger ?? this.#options.customMerger,
86
+ outputMimeType: options.outputMimeType ?? this.#options.outputMimeType,
87
+ postMergeValidator: options.postMergeValidator ?? this.#options.postMergeValidator,
88
+ resumeValidation: options.resumeValidation ?? this.#options.resumeValidation,
73
89
  });
74
90
  }
75
91
 
package/src/index.ts CHANGED
@@ -21,6 +21,9 @@ export type {
21
21
  SynthesizedChunk,
22
22
  PartialChunkSynthesisResult,
23
23
  PartialSynthesisResult,
24
+ ChunkExecutionState,
25
+ ChunkExecutionStatus,
26
+ ResumeValidationMode,
24
27
  CustomMergerContext,
25
28
  CustomAudioMerger,
26
29
  PostMergeValidator,
@@ -47,6 +50,7 @@ export {
47
50
  inspectAudioSpecification,
48
51
  resolveMergeAudioFormat,
49
52
  synthesizeSsmlChunks,
53
+ computeChunkFingerprint,
50
54
  } from "./synthesis.ts";
51
55
  export type {
52
56
  InputAudioSpecs,
@@ -49,6 +49,9 @@ export type AzureTtsOutputFormat = keyof typeof OUTPUT_FORMATS;
49
49
  export function resolveMimeType(outputFormat: string): string {
50
50
  if (/(?:wav|wave|riff)/i.test(outputFormat)) return "audio/wav";
51
51
  if (/(?:mp3|mpeg)/i.test(outputFormat)) return "audio/mpeg";
52
+ if (/mulaw|mu-law/i.test(outputFormat)) return "audio/basic";
53
+ if (/alaw|a-law/i.test(outputFormat)) return "audio/alaw";
54
+ if (/siren/i.test(outputFormat)) return "audio/siren";
52
55
  if (/ogg/i.test(outputFormat)) return "audio/ogg";
53
56
  if (/webm/i.test(outputFormat)) return "audio/webm";
54
57
  if (/raw/i.test(outputFormat)) return "audio/L16";
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,6 +25,8 @@ 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";
30
32
 
@@ -140,6 +142,7 @@ export interface SynthesizeSsmlChunksSafeOptions extends AzureValidationOptions
140
142
  customMerger?: CustomAudioMerger;
141
143
  outputMimeType?: string;
142
144
  postMergeValidator?: PostMergeValidator;
145
+ resumeValidation?: ResumeValidationMode;
143
146
  }
144
147
 
145
148
  export type SsmlSynthesisChunksSafeResult =
@@ -225,6 +228,7 @@ async function retryableSynthesis(
225
228
  options: RetryOptions | undefined,
226
229
  signal: AbortSignal | undefined,
227
230
  onRetry: (attempt: number, delayMs: number) => void,
231
+ deadlineAtMs?: number,
228
232
  ): Promise<SsmlSynthesisResult> {
229
233
  const retry = options
230
234
  ? {
@@ -244,6 +248,14 @@ async function retryableSynthesis(
244
248
  throw error;
245
249
  attempt += 1;
246
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
+ }
247
259
  onRetry(attempt, delayMs);
248
260
  if (delayMs > 0)
249
261
  await new Promise<void>((resolve, reject) => {
@@ -306,20 +318,27 @@ export async function synthesizeSsmlSafe(
306
318
  });
307
319
  }
308
320
 
321
+ const jobScope =
322
+ options.timeouts?.totalJobMs !== undefined
323
+ ? createSafeAbortScope(options.signal, options.timeouts.totalJobMs)
324
+ : undefined;
309
325
  try {
310
326
  return {
311
327
  ok: true,
312
328
  success: true,
313
329
  status: "success",
314
330
  value: await client.synthesizeSsml(ssml, {
315
- signal: options.signal,
331
+ signal: jobScope?.signal ?? options.signal,
316
332
  timeoutMs: options.timeouts?.perChunkMs,
317
333
  timeouts: options.timeouts,
318
334
  }),
319
335
  };
320
336
  } catch (error) {
337
+ if (jobScope?.timedOut()) return failure(toSynthesisError(new Error("Speech synthesis timed out.")));
321
338
  const synthesisError = toSynthesisError(error);
322
339
  return failure(synthesisError);
340
+ } finally {
341
+ jobScope?.dispose();
323
342
  }
324
343
  }
325
344
 
@@ -403,19 +422,38 @@ export async function synthesizeSsmlChunksSafe(
403
422
  customMerger: options.customMerger,
404
423
  outputMimeType: options.outputMimeType,
405
424
  postMergeValidator: options.postMergeValidator,
425
+ resumeValidation: options.resumeValidation,
406
426
  });
407
427
  return { ok: true, success: true, status: "success", value };
408
428
  }
429
+ const inputs = chunks.map((chunk) => (typeof chunk === "string" ? { ssml: chunk } : chunk));
430
+ const fingerprints = inputs.map((chunk) => computeChunkFingerprint(chunk.ssml, options.outputFormat));
409
431
  const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
432
+ const chunkStates: ChunkExecutionState[] = inputs.map((_chunk, chunkIndex) => ({
433
+ chunkIndex,
434
+ status: "pending",
435
+ canResume: true,
436
+ }));
410
437
  const cachedChunks = new Map((options.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
438
+ const invalidCachedIndices = new Set<number>();
411
439
  for (const [index, cached] of cachedChunks) {
412
- if (index >= 0 && index < chunks.length) results[index] = cached;
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);
413
445
  }
414
446
  const requestedIndices = options.resumeChunkIndices
415
447
  ? new Set(options.resumeChunkIndices.filter((index) => index >= 0 && index < chunks.length))
416
448
  : undefined;
417
449
  const shouldSynthesize = (index: number): boolean =>
418
- !cachedChunks.has(index) && (requestedIndices === undefined || requestedIndices.has(index));
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;
419
457
  const jobScope =
420
458
  chunks.length > 1 || options.timeouts?.totalJobMs !== undefined
421
459
  ? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs)
@@ -431,7 +469,10 @@ export async function synthesizeSsmlChunksSafe(
431
469
  const index = nextIndex++;
432
470
  if (index >= chunks.length) return;
433
471
  if (!shouldSynthesize(index)) continue;
434
- if (failedIndices.size > 0 && options.cancelOnFailure !== false) return;
472
+ if (firstError && options.cancelOnFailure !== false) {
473
+ chunkStates[index] = { chunkIndex: index, status: "cancelled", isOriginalFailure: false, canResume: true };
474
+ return;
475
+ }
435
476
  const chunk = chunks[index];
436
477
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
437
478
  const sourceNodePath = input.sourceNodePath;
@@ -470,6 +511,7 @@ export async function synthesizeSsmlChunksSafe(
470
511
  nextRetryDelayMs,
471
512
  isRetrying: true,
472
513
  }),
514
+ jobDeadlineAt,
473
515
  );
474
516
  } catch (error) {
475
517
  if (chunkScope?.timedOut())
@@ -547,6 +589,7 @@ export async function synthesizeSsmlChunksSafe(
547
589
  }
548
590
  : {}),
549
591
  };
592
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
550
593
  completed += 1;
551
594
  options.onProgress?.({
552
595
  currentChunk: completed,
@@ -558,7 +601,16 @@ export async function synthesizeSsmlChunksSafe(
558
601
  durationMs: Date.now() - startedAt,
559
602
  });
560
603
  } catch (error) {
561
- failedIndices.add(index);
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
+ };
562
614
  options.onProgress?.({
563
615
  currentChunk: completed,
564
616
  totalChunks: chunks.length,
@@ -570,19 +622,36 @@ export async function synthesizeSsmlChunksSafe(
570
622
  error,
571
623
  });
572
624
  if (options.cancelOnFailure !== false) jobScope?.abort();
573
- firstError ??= error;
574
625
  return;
575
626
  }
576
627
  }
577
628
  };
578
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
+ }
579
637
  if (failedIndices.size > 0) {
580
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
+ );
581
642
  (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])),
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
+ ),
585
650
  failedChunkIndices: [...failedIndices],
651
+ cancelledChunkIndices: chunkStates
652
+ .filter((state) => state.status === "cancelled")
653
+ .map((state) => state.chunkIndex),
654
+ chunkStates,
586
655
  totalChunks: chunks.length,
587
656
  };
588
657
  throw error;