@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/CHANGELOG.md +11 -0
- package/dist/index.d.mts +52 -5
- package/dist/index.d.ts +52 -5
- package/dist/index.js +510 -154
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +512 -155
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +4 -0
- package/src/errors.ts +23 -1
- package/src/index.ts +12 -1
- package/src/safe.ts +238 -111
- package/src/synthesis.ts +361 -66
- package/src/types.ts +31 -0
- package/test/v216-pipeline.test.ts +110 -0
package/src/safe.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
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 =
|
|
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, {
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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(
|
|
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) {
|