@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/CHANGELOG.md +10 -0
- package/dist/index.d.mts +169 -140
- package/dist/index.d.ts +169 -140
- package/dist/index.js +250 -56
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +249 -56
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +21 -5
- package/src/index.ts +4 -0
- package/src/outputFormats.ts +3 -0
- package/src/safe.ts +79 -10
- package/src/synthesis.ts +214 -41
- package/src/types.ts +27 -1
- package/test/v218-pipeline.test.ts +91 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssml-builder-js/azure-tts-client",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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 = {
|
|
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,
|
package/src/outputFormats.ts
CHANGED
|
@@ -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
|
|
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)
|
|
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 (
|
|
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
|
-
|
|
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
|
|
583
|
-
completedChunks:
|
|
584
|
-
pendingChunkIndices:
|
|
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;
|