@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/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.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.17.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,16 +19,45 @@ 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
 
26
- const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts };
35
+ const config = {
36
+ endpoint,
37
+ region,
38
+ subscriptionKey,
39
+ outputFormat,
40
+ signal,
41
+ timeoutMs,
42
+ timeouts,
43
+ retryOptions: this.#options.retryOptions,
44
+ customHeaders,
45
+ fingerprintSchemaVersion,
46
+ };
27
47
  return synthesizeSpeech(ssml, config);
28
48
  }
29
49
 
30
50
  async synthesizeSsml(ssml: string, options: Partial<TtsConfig> = {}): Promise<SsmlSynthesisResult> {
31
- 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;
32
61
  const endpoint = this.#options.endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
33
62
  this.#options.logger?.debug?.("Using Azure TTS endpoint:", endpoint);
34
63
 
@@ -43,6 +72,14 @@ export class AzureTtsClient {
43
72
  sourceNodePath: options.sourceNodePath,
44
73
  sourceTextSegments: options.sourceTextSegments,
45
74
  sourceMarkers: options.sourceMarkers,
75
+ retryOptions: options.retryOptions ?? this.#options.retryOptions,
76
+ cancelOnFailure: options.cancelOnFailure ?? this.#options.cancelOnFailure,
77
+ customMerger: options.customMerger ?? this.#options.customMerger,
78
+ outputMimeType: options.outputMimeType ?? this.#options.outputMimeType,
79
+ postMergeValidator: options.postMergeValidator ?? this.#options.postMergeValidator,
80
+ resumeValidation: options.resumeValidation ?? this.#options.resumeValidation,
81
+ customHeaders: options.customHeaders ?? customHeaders,
82
+ fingerprintSchemaVersion: options.fingerprintSchemaVersion ?? fingerprintSchemaVersion,
46
83
  });
47
84
  }
48
85
 
@@ -50,7 +87,16 @@ export class AzureTtsClient {
50
87
  chunks: readonly (SsmlSynthesisChunk | string)[],
51
88
  options: SynthesizeChunksOptions = {},
52
89
  ): Promise<SsmlSynthesisResult> {
53
- 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;
54
100
  const endpoint = this.#options.endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
55
101
  return synthesizeSsmlChunks(chunks, {
56
102
  endpoint,
@@ -64,12 +110,15 @@ export class AzureTtsClient {
64
110
  onProgress: options.onProgress ?? this.#options.onProgress,
65
111
  concurrency: options.concurrency ?? this.#options.concurrency,
66
112
  retryOptions: options.retryOptions ?? this.#options.retryOptions,
67
- cancelOnFailure: options.cancelOnFailure,
113
+ cancelOnFailure: options.cancelOnFailure ?? this.#options.cancelOnFailure,
68
114
  resumeChunks: options.resumeChunks,
69
115
  resumeChunkIndices: options.resumeChunkIndices,
70
- customMerger: options.customMerger,
71
- outputMimeType: options.outputMimeType,
72
- postMergeValidator: options.postMergeValidator,
116
+ customMerger: options.customMerger ?? this.#options.customMerger,
117
+ outputMimeType: options.outputMimeType ?? this.#options.outputMimeType,
118
+ postMergeValidator: options.postMergeValidator ?? this.#options.postMergeValidator,
119
+ resumeValidation: options.resumeValidation ?? this.#options.resumeValidation,
120
+ customHeaders: options.customHeaders ?? customHeaders,
121
+ fingerprintSchemaVersion: options.fingerprintSchemaVersion ?? fingerprintSchemaVersion,
73
122
  });
74
123
  }
75
124
 
@@ -90,6 +139,13 @@ export class AzureTtsClient {
90
139
  onProgress: options.onProgress ?? this.#options.onProgress,
91
140
  concurrency: options.concurrency ?? this.#options.concurrency,
92
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,
93
149
  });
94
150
  }
95
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
@@ -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,
@@ -33,10 +36,19 @@ export {
33
36
  MergeError,
34
37
  SynthesisCancelledError,
35
38
  SynthesisTimeoutError,
39
+ IncompleteChunkSetError,
40
+ serializeChunkError,
36
41
  UnsupportedMergeFormatError,
37
42
  getRetryAfterDelayMs,
38
43
  } from "./errors.ts";
39
- 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";
40
52
  export { AzureTtsClient } from "./client.ts";
41
53
  export { synthesizeSpeech } from "./synthesis.ts";
42
54
  export { synthesizeSsml } from "./synthesis.ts";
@@ -47,9 +59,11 @@ export {
47
59
  inspectAudioSpecification,
48
60
  resolveMergeAudioFormat,
49
61
  synthesizeSsmlChunks,
62
+ computeChunkFingerprint,
50
63
  } from "./synthesis.ts";
51
64
  export type {
52
65
  InputAudioSpecs,
66
+ ChunkFingerprintOptions,
53
67
  MergeAudioFormat,
54
68
  MergeAudioOptions,
55
69
  MergeSynthesisOptions,
@@ -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";