@ssml-builder-js/azure-tts-client 2.15.0 → 2.16.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,4 +1,10 @@
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,
@@ -12,6 +18,7 @@ import type {
12
18
  SsmlSynthesisResult,
13
19
  SynthesisProgressEvent,
14
20
  SynthesizeChunksOptions,
21
+ RetryOptions,
15
22
  } from "./types.ts";
16
23
  import type { AzureTtsOutputFormat } from "./outputFormats.ts";
17
24
 
@@ -83,6 +90,8 @@ export interface SynthesizeSsmlChunksSafeOptions extends AzureValidationOptions
83
90
  timeoutMs?: number;
84
91
  sourceNodePath?: string[];
85
92
  onProgress?: (event: SynthesisProgressEvent) => void;
93
+ concurrency?: number;
94
+ retryOptions?: RetryOptions;
86
95
  }
87
96
 
88
97
  export type SsmlSynthesisChunksSafeResult =
@@ -98,13 +107,91 @@ interface SynthesisClient {
98
107
  ): Promise<SsmlSynthesisResult>;
99
108
  }
100
109
 
110
+ function isRetryable(error: unknown): boolean {
111
+ if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
112
+ const status =
113
+ error && typeof error === "object" && "status" in error ? (error as { status?: unknown }).status : undefined;
114
+ if (typeof status === "number" && status !== 0) return status === 429 || (status >= 500 && status < 600);
115
+ const message = error instanceof Error ? error.message : String(error);
116
+ if (/\b4\d{2}\b/.test(message)) return false;
117
+ return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
118
+ }
119
+
120
+ function delayForRetry(options: RetryOptions, attempt: number): number {
121
+ const maxDelay = Math.max(0, options.maxDelayMs);
122
+ const base = Math.min(maxDelay, Math.max(0, options.initialDelayMs) * 2 ** Math.max(0, attempt - 1));
123
+ return Math.floor(Math.random() * (base + 1));
124
+ }
125
+
126
+ function resolveConcurrency(value: number | undefined, total: number): number {
127
+ if (value === undefined) return 1;
128
+ if (value === Infinity) return Math.max(1, total);
129
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
130
+ }
131
+
132
+ async function retryableSynthesis(
133
+ synthesize: () => Promise<SsmlSynthesisResult>,
134
+ options: RetryOptions | undefined,
135
+ signal: AbortSignal | undefined,
136
+ onRetry: (attempt: number, delayMs: number) => void,
137
+ ): Promise<SsmlSynthesisResult> {
138
+ const retry = options
139
+ ? {
140
+ maxRetries: Math.max(0, Math.floor(options.maxRetries)),
141
+ initialDelayMs: options.initialDelayMs,
142
+ maxDelayMs: options.maxDelayMs,
143
+ }
144
+ : undefined;
145
+ let attempt = 0;
146
+ while (true) {
147
+ if (signal?.aborted) throw new Error("Speech synthesis was cancelled.");
148
+ try {
149
+ return await synthesize();
150
+ } catch (error) {
151
+ if (!retry || attempt >= retry.maxRetries || !isRetryable(error)) throw error;
152
+ attempt += 1;
153
+ const delayMs = delayForRetry(retry, attempt);
154
+ onRetry(attempt, delayMs);
155
+ if (delayMs > 0)
156
+ await new Promise<void>((resolve, reject) => {
157
+ const timer = setTimeout(() => {
158
+ signal?.removeEventListener("abort", abort);
159
+ resolve();
160
+ }, delayMs);
161
+ const abort = () => {
162
+ clearTimeout(timer);
163
+ signal?.removeEventListener("abort", abort);
164
+ reject(new Error("Speech synthesis was cancelled."));
165
+ };
166
+ signal?.addEventListener("abort", abort, { once: true });
167
+ });
168
+ }
169
+ }
170
+ }
171
+
172
+ function sharedValidationOptions(options: AzureValidationOptions, signal?: AbortSignal): AzureValidationOptions {
173
+ const validator = options.urlValidator ?? options.customUrlValidator;
174
+ if (!validator) return signal ? withValidationSignal(options, signal) : options;
175
+ const runner = createAzureUrlValidatorRunner(validator as AzureUrlValidator, {
176
+ ...(options.urlValidation ?? {}),
177
+ ...(options.urlValidatorConcurrency !== undefined ? { concurrency: options.urlValidatorConcurrency } : {}),
178
+ ...(options.urlValidatorTimeoutMs !== undefined ? { timeoutMs: options.urlValidatorTimeoutMs } : {}),
179
+ ...(signal ? { signal } : {}),
180
+ ...(options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}),
181
+ });
182
+ return {
183
+ ...withValidationSignal(options, signal),
184
+ urlValidatorRunner: runner,
185
+ };
186
+ }
187
+
101
188
  /** Validates SSML before invoking Azure and converts validation/API failures to one result shape. */
102
189
  export async function synthesizeSsmlSafe(
103
190
  client: Pick<AzureTtsClient, "synthesizeSsml"> | SynthesisClient,
104
191
  ssml: string,
105
192
  options: SynthesizeSsmlSafeOptions = {},
106
193
  ): Promise<SsmlSynthesisSafeResult> {
107
- const validationOptions = withValidationSignal(options.validation ?? options, options.signal);
194
+ const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
108
195
  const diagnostics = await Promise.resolve(validateAzureSsml(ssml, validationOptions));
109
196
  if (options.signal?.aborted) {
110
197
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
@@ -138,7 +225,7 @@ export async function synthesizeSsmlChunksSafe(
138
225
  chunks: readonly (SsmlSynthesisChunk | string)[],
139
226
  options: SynthesizeSsmlChunksSafeOptions = {},
140
227
  ): Promise<SsmlSynthesisChunksSafeResult> {
141
- const validationOptions = withValidationSignal(options.validation ?? options, options.signal);
228
+ const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
142
229
  if (options.signal?.aborted) {
143
230
  const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
144
231
  return failure(error);
@@ -160,17 +247,25 @@ export async function synthesizeSsmlChunksSafe(
160
247
  pending(index, "pending");
161
248
  });
162
249
  const validations = await Promise.all(
163
- chunks.map(async (chunk) => {
250
+ chunks.map(async (chunk, index) => {
164
251
  const ssml = typeof chunk === "string" ? chunk : chunk.ssml;
165
252
  const sourceNodePath =
166
253
  typeof chunk === "string" ? options.sourceNodePath : (chunk.sourceNodePath ?? options.sourceNodePath);
167
254
  const diagnostics = await Promise.resolve(
168
- validateAzureSsml(ssml, { ...validationOptions, ...(sourceNodePath ? { sourceNodePath } : {}) }),
255
+ validateAzureSsml(ssml, {
256
+ ...validationOptions,
257
+ ...(sourceNodePath ? { sourceNodePath } : {}),
258
+ chunkIndex: index,
259
+ }),
169
260
  );
170
261
  return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
171
262
  }),
172
263
  );
173
264
  const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
265
+ if (options.signal?.aborted) {
266
+ const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
267
+ return failure(error);
268
+ }
174
269
  if (firstInvalidIndex >= 0) {
175
270
  const error = new ChunkValidationError(firstInvalidIndex, validations[firstInvalidIndex] ?? []);
176
271
  pending(firstInvalidIndex, "failed", error);
@@ -189,121 +284,153 @@ export async function synthesizeSsmlChunksSafe(
189
284
  signal: options.signal,
190
285
  timeoutMs: options.timeoutMs,
191
286
  sourceNodePath: options.sourceNodePath,
287
+ concurrency: options.concurrency,
288
+ retryOptions: options.retryOptions,
192
289
  });
193
290
  return { ok: true, success: true, status: "success", value };
194
291
  }
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;
292
+ const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
293
+ let completed = 0;
294
+ let nextIndex = 0;
295
+ const concurrency = resolveConcurrency(options.concurrency, chunks.length);
296
+ const worker = async (): Promise<void> => {
297
+ while (true) {
298
+ const index = nextIndex++;
299
+ if (index >= chunks.length) return;
300
+ const chunk = chunks[index];
301
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
302
+ const sourceNodePath = input.sourceNodePath;
303
+ const originalTextRange = input.originalTextRange;
304
+ pending(index, "synthesizing");
305
+ const startedAt = Date.now();
306
+ try {
307
+ const result = await retryableSynthesis(
308
+ () =>
309
+ client.synthesizeSsml(input.ssml, {
310
+ outputFormat: options.outputFormat,
311
+ signal: options.signal,
312
+ timeoutMs: options.timeoutMs,
313
+ sourceNodePath: input.sourceNodePath ?? options.sourceNodePath,
314
+ }),
315
+ options.retryOptions,
316
+ options.signal,
317
+ (retryAttempt, nextRetryDelayMs) =>
318
+ options.onProgress?.({
319
+ currentChunk: completed,
320
+ totalChunks: chunks.length,
321
+ percent: chunks.length === 0 ? 100 : Math.round((completed / chunks.length) * 100),
322
+ chunkIndex: index,
323
+ originalTextRange: input.originalTextRange,
324
+ status: "synthesizing",
325
+ durationMs: Date.now() - startedAt,
326
+ retryAttempt,
327
+ nextRetryDelayMs,
328
+ isRetrying: true,
329
+ }),
330
+ );
331
+ results[index] = {
332
+ ...result,
333
+ ...(input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {}),
334
+ ...(sourceNodePath
335
+ ? {
336
+ boundaries: result.boundaries?.map((event) => ({
337
+ ...event,
338
+ sourceNodePath: [...sourceNodePath],
339
+ ...(event.originalTextRange
340
+ ? { originalTextRange: { ...event.originalTextRange } }
341
+ : input.originalTextRange
342
+ ? { originalTextRange: { ...input.originalTextRange } }
343
+ : {}),
344
+ })),
345
+ visemes: result.visemes?.map((event) => ({
346
+ ...event,
347
+ sourceNodePath: [...sourceNodePath],
348
+ ...(event.originalTextRange
349
+ ? { originalTextRange: { ...event.originalTextRange } }
350
+ : input.originalTextRange
351
+ ? { originalTextRange: { ...input.originalTextRange } }
352
+ : {}),
353
+ })),
354
+ bookmarks: result.bookmarks?.map((event) => ({
355
+ ...event,
356
+ sourceNodePath: [...sourceNodePath],
357
+ ...(event.originalTextRange
358
+ ? { originalTextRange: { ...event.originalTextRange } }
359
+ : input.originalTextRange
360
+ ? { originalTextRange: { ...input.originalTextRange } }
361
+ : {}),
362
+ })),
363
+ }
364
+ : {}),
365
+ ...(originalTextRange
366
+ ? {
367
+ boundaries: result.boundaries?.map((event) => ({
368
+ ...event,
369
+ originalTextRange: event.originalTextRange
370
+ ? { ...event.originalTextRange }
371
+ : { ...originalTextRange },
372
+ })),
373
+ wordBoundary: result.wordBoundary?.map((event) => ({
374
+ ...event,
375
+ originalTextRange: event.originalTextRange
376
+ ? { ...event.originalTextRange }
377
+ : { ...originalTextRange },
378
+ })),
379
+ wordBoundaries: result.wordBoundaries?.map((event) => ({
380
+ ...event,
381
+ originalTextRange: event.originalTextRange
382
+ ? { ...event.originalTextRange }
383
+ : { ...originalTextRange },
384
+ })),
385
+ visemes: result.visemes?.map((event) => ({
386
+ ...event,
387
+ originalTextRange: event.originalTextRange
388
+ ? { ...event.originalTextRange }
389
+ : { ...originalTextRange },
390
+ })),
391
+ bookmarks: result.bookmarks?.map((event) => ({
392
+ ...event,
393
+ originalTextRange: event.originalTextRange
394
+ ? { ...event.originalTextRange }
395
+ : { ...originalTextRange },
396
+ })),
397
+ }
398
+ : {}),
399
+ };
400
+ completed += 1;
401
+ options.onProgress?.({
402
+ currentChunk: completed,
403
+ totalChunks: chunks.length,
404
+ percent: chunks.length === 0 ? 100 : Math.round((completed / chunks.length) * 100),
405
+ chunkIndex: index,
406
+ originalTextRange: input.originalTextRange,
407
+ status: "success",
408
+ durationMs: Date.now() - startedAt,
409
+ });
410
+ } catch (error) {
411
+ options.onProgress?.({
412
+ currentChunk: completed,
413
+ totalChunks: chunks.length,
414
+ percent: chunks.length === 0 ? 100 : Math.round((index / chunks.length) * 100),
415
+ chunkIndex: index,
416
+ originalTextRange: input.originalTextRange,
417
+ status: "failed",
418
+ durationMs: Date.now() - startedAt,
419
+ error,
420
+ });
421
+ throw error;
422
+ }
299
423
  }
300
- }
424
+ };
425
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
426
+ const orderedResults = results.filter((result): result is SsmlSynthesisResult => result !== undefined);
301
427
  return {
302
428
  ok: true,
303
429
  success: true,
304
430
  status: "success",
305
- value: mergeSynthesisResults(results, {
431
+ value: mergeSynthesisResults(orderedResults, {
306
432
  format: (options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3") as AzureTtsOutputFormat,
433
+ signal: options.signal,
307
434
  }),
308
435
  };
309
436
  } catch (error) {