@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/CHANGELOG.md +20 -0
- package/dist/index.d.mts +123 -43
- package/dist/index.d.ts +123 -43
- package/dist/index.js +674 -90
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +670 -90
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +64 -8
- package/src/deadline.ts +52 -0
- package/src/errors.ts +86 -4
- package/src/index.ts +15 -1
- package/src/outputFormats.ts +3 -0
- package/src/safe.ts +115 -25
- package/src/synthesis.ts +508 -50
- package/src/types.ts +32 -1
- package/test/v218-pipeline.test.ts +91 -0
- package/test/v219-pipeline.test.ts +131 -0
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
|
|
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 (
|
|
297
|
-
const error = toSynthesisError(
|
|
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:
|
|
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
|
-
|
|
359
|
+
deadline.signal,
|
|
335
360
|
);
|
|
336
|
-
if (
|
|
337
|
-
const error = toSynthesisError(
|
|
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 (
|
|
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:
|
|
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
|
|
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)
|
|
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(
|
|
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 (
|
|
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 ??
|
|
500
|
+
? createSafeAbortScope(jobScope?.signal ?? deadline.signal, chunkTimeout ?? options.timeoutMs)
|
|
446
501
|
: undefined;
|
|
447
|
-
const chunkSignal = chunkScope?.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
|
-
|
|
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
|
|
583
|
-
completedChunks:
|
|
584
|
-
pendingChunkIndices:
|
|
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 ??
|
|
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
|
|