@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/src/synthesis.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
RetryOptions,
|
|
23
23
|
CustomAudioMerger,
|
|
24
24
|
PostMergeValidator,
|
|
25
|
+
ChunkExecutionState,
|
|
25
26
|
} from "./types.ts";
|
|
26
27
|
|
|
27
28
|
export type MergeAudioFormat = "wav" | "mp3" | "raw";
|
|
@@ -34,6 +35,33 @@ export interface MergeAudioOptions {
|
|
|
34
35
|
|
|
35
36
|
export type InputAudioSpecs = AudioSpecification[];
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Creates a deterministic, runtime-independent fingerprint for a synthesis chunk.
|
|
40
|
+
* The complete SSML is included so changes to voice, language, prosody, or text
|
|
41
|
+
* invalidate a cached result even when those settings are nested in the markup.
|
|
42
|
+
*/
|
|
43
|
+
export function computeChunkFingerprint(ssml: string, outputFormat = DEFAULT_OUTPUT_FORMAT): string {
|
|
44
|
+
const readAttribute = (name: string): string => {
|
|
45
|
+
const pattern = new RegExp(`(?:${name})\\s*=\\s*[\\"']([^\\"']*)`, "gi");
|
|
46
|
+
return [...ssml.matchAll(pattern)].map((match) => match[1] ?? "").join("|");
|
|
47
|
+
};
|
|
48
|
+
const payload = JSON.stringify({
|
|
49
|
+
ssml,
|
|
50
|
+
outputFormat,
|
|
51
|
+
voice: readAttribute("(?:name|voice)"),
|
|
52
|
+
language: readAttribute("(?:xml:lang|lang)"),
|
|
53
|
+
rate: readAttribute("rate"),
|
|
54
|
+
pitch: readAttribute("pitch"),
|
|
55
|
+
});
|
|
56
|
+
let hash = 0xcbf29ce484222325n;
|
|
57
|
+
const mask = 0xffffffffffffffffn;
|
|
58
|
+
for (let index = 0; index < payload.length; index += 1) {
|
|
59
|
+
hash ^= BigInt(payload.charCodeAt(index));
|
|
60
|
+
hash = (hash * 0x100000001b3n) & mask;
|
|
61
|
+
}
|
|
62
|
+
return `fnv1a64-${hash.toString(16).padStart(16, "0")}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
37
65
|
export interface MergeSynthesisOptions extends MergeAudioOptions {
|
|
38
66
|
customMerger?: CustomAudioMerger;
|
|
39
67
|
postMergeValidator?: PostMergeValidator;
|
|
@@ -96,9 +124,11 @@ function parseWav(buffer: ArrayBuffer): ParsedWav {
|
|
|
96
124
|
return { chunks, data, format };
|
|
97
125
|
}
|
|
98
126
|
|
|
99
|
-
function
|
|
100
|
-
const match =
|
|
101
|
-
|
|
127
|
+
function formatSampleRate(format: string): number {
|
|
128
|
+
const match = /(?:^|-)(\d+)(khz|hz)(?:-|$)/i.exec(format);
|
|
129
|
+
if (!match?.[1] || !match[2]) return 0;
|
|
130
|
+
const value = Number(match[1]);
|
|
131
|
+
return match[2].toLowerCase() === "khz" ? value * 1000 : value;
|
|
102
132
|
}
|
|
103
133
|
|
|
104
134
|
function formatChannels(format: string, fallback: number): number {
|
|
@@ -108,7 +138,7 @@ function formatChannels(format: string, fallback: number): number {
|
|
|
108
138
|
}
|
|
109
139
|
|
|
110
140
|
function formatAudioSpecification(format: string): AudioSpecification {
|
|
111
|
-
const sampleRate =
|
|
141
|
+
const sampleRate = formatSampleRate(format);
|
|
112
142
|
const channels = formatChannels(format, 0);
|
|
113
143
|
const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
|
|
114
144
|
const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1000 : undefined;
|
|
@@ -118,9 +148,15 @@ function formatAudioSpecification(format: string): AudioSpecification {
|
|
|
118
148
|
? "opus"
|
|
119
149
|
: /silk/i.test(format)
|
|
120
150
|
? "silk"
|
|
121
|
-
: /
|
|
122
|
-
? "
|
|
123
|
-
:
|
|
151
|
+
: /mulaw|mu-law/i.test(format)
|
|
152
|
+
? "mulaw"
|
|
153
|
+
: /alaw|a-law/i.test(format)
|
|
154
|
+
? "alaw"
|
|
155
|
+
: /siren/i.test(format)
|
|
156
|
+
? "siren"
|
|
157
|
+
: /pcm/i.test(format)
|
|
158
|
+
? "pcm"
|
|
159
|
+
: "unknown";
|
|
124
160
|
const bitDepthMatch = /(\d+)bit/i.exec(format);
|
|
125
161
|
const container = /(?:wav|wave|riff)/i.test(format)
|
|
126
162
|
? "riff-wave"
|
|
@@ -143,7 +179,7 @@ function formatAudioSpecification(format: string): AudioSpecification {
|
|
|
143
179
|
...(bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {}),
|
|
144
180
|
...(container ? { container } : {}),
|
|
145
181
|
isVbr: /vbr/i.test(format),
|
|
146
|
-
isCompressed: codec
|
|
182
|
+
isCompressed: codec !== "pcm" && codec !== "unknown",
|
|
147
183
|
};
|
|
148
184
|
}
|
|
149
185
|
|
|
@@ -197,21 +233,47 @@ export function inspectAudioSpecification(buffer: ArrayBuffer, format: string):
|
|
|
197
233
|
const channels = view.getUint16(2, true);
|
|
198
234
|
const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
|
|
199
235
|
const formatCode = view.getUint16(0, true);
|
|
236
|
+
const namedCodec = formatAudioSpecification(format).codec;
|
|
237
|
+
const codec: AudioSpecification["codec"] =
|
|
238
|
+
formatCode === 1
|
|
239
|
+
? "pcm"
|
|
240
|
+
: formatCode === 6
|
|
241
|
+
? "alaw"
|
|
242
|
+
: formatCode === 7
|
|
243
|
+
? "mulaw"
|
|
244
|
+
: namedCodec === "siren"
|
|
245
|
+
? "siren"
|
|
246
|
+
: "unknown";
|
|
200
247
|
return {
|
|
201
248
|
format,
|
|
202
249
|
mimeType: "audio/wav",
|
|
203
|
-
codec
|
|
250
|
+
codec,
|
|
204
251
|
sampleRate,
|
|
205
252
|
channels,
|
|
206
253
|
...(sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {}),
|
|
207
254
|
bitDepth: bitsPerSample,
|
|
208
255
|
container: "riff-wave",
|
|
209
256
|
isVbr: false,
|
|
210
|
-
isCompressed:
|
|
257
|
+
isCompressed: codec !== "pcm" && codec !== "unknown",
|
|
211
258
|
};
|
|
212
259
|
}
|
|
213
260
|
if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
|
|
214
|
-
|
|
261
|
+
const specification = formatAudioSpecification(format);
|
|
262
|
+
if (specification.container === "raw") validateRawAudioBuffer(buffer, specification);
|
|
263
|
+
return specification;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function validateRawAudioBuffer(buffer: ArrayBuffer, specification: AudioSpecification): void {
|
|
267
|
+
if (specification.sampleRate <= 0 || specification.channels <= 0 || specification.bitDepth === undefined) {
|
|
268
|
+
throw new Error(`RAW audio format "${specification.format}" does not define a complete audio specification.`);
|
|
269
|
+
}
|
|
270
|
+
if (specification.codec === "siren" || specification.codec === "silk") return;
|
|
271
|
+
const bytesPerFrame = specification.channels * Math.ceil(specification.bitDepth / 8);
|
|
272
|
+
if (bytesPerFrame <= 0 || buffer.byteLength % bytesPerFrame !== 0) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`RAW audio buffer size ${buffer.byteLength} is not aligned to ${bytesPerFrame}-byte audio frames for "${specification.format}".`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
215
277
|
}
|
|
216
278
|
|
|
217
279
|
function validateAudioSpecifications(specs: readonly AudioSpecification[]): void {
|
|
@@ -221,6 +283,7 @@ function validateAudioSpecifications(specs: readonly AudioSpecification[]): void
|
|
|
221
283
|
(spec) =>
|
|
222
284
|
spec.sampleRate !== first.sampleRate ||
|
|
223
285
|
spec.channels !== first.channels ||
|
|
286
|
+
spec.codec !== first.codec ||
|
|
224
287
|
(first.bitrate !== undefined && spec.bitrate !== undefined && spec.bitrate !== first.bitrate) ||
|
|
225
288
|
(first.bitDepth !== undefined && spec.bitDepth !== undefined && spec.bitDepth !== first.bitDepth) ||
|
|
226
289
|
(first.container !== undefined && spec.container !== undefined && spec.container !== first.container) ||
|
|
@@ -323,6 +386,43 @@ function isRawFormat(format: string): boolean {
|
|
|
323
386
|
return /^raw(?:-|$)/i.test(format);
|
|
324
387
|
}
|
|
325
388
|
|
|
389
|
+
function validateMergedAudioBuffer(
|
|
390
|
+
merged: ArrayBuffer,
|
|
391
|
+
format: string,
|
|
392
|
+
buffers: readonly ArrayBuffer[],
|
|
393
|
+
inputSpecs: readonly AudioSpecification[],
|
|
394
|
+
outputMimeType: string,
|
|
395
|
+
): AudioSpecification {
|
|
396
|
+
if (!(merged instanceof ArrayBuffer) || merged.byteLength === 0) {
|
|
397
|
+
throw new MergeError("The custom audio merger returned an empty or invalid audio buffer.");
|
|
398
|
+
}
|
|
399
|
+
const specification = inspectAudioSpecification(merged, format);
|
|
400
|
+
if (!outputMimeType.trim()) throw new MergeError("The custom audio merger output MIME type cannot be empty.");
|
|
401
|
+
const firstInput = inputSpecs[0];
|
|
402
|
+
if (
|
|
403
|
+
firstInput &&
|
|
404
|
+
(specification.sampleRate !== firstInput.sampleRate ||
|
|
405
|
+
specification.channels !== firstInput.channels ||
|
|
406
|
+
specification.codec !== firstInput.codec ||
|
|
407
|
+
(firstInput.bitDepth !== undefined && specification.bitDepth !== firstInput.bitDepth))
|
|
408
|
+
) {
|
|
409
|
+
throw new AudioFormatMismatchError("The custom audio merger returned an incompatible audio stream.", [
|
|
410
|
+
...inputSpecs,
|
|
411
|
+
specification,
|
|
412
|
+
]);
|
|
413
|
+
}
|
|
414
|
+
if (isRawFormat(format)) {
|
|
415
|
+
const expectedSize = buffers.reduce((total, input) => total + input.byteLength, 0);
|
|
416
|
+
if (merged.byteLength !== expectedSize) {
|
|
417
|
+
throw new MergeError(
|
|
418
|
+
`The custom audio merger returned ${merged.byteLength} bytes; ${expectedSize} were expected.`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
validateRawAudioBuffer(merged, specification);
|
|
422
|
+
}
|
|
423
|
+
return specification;
|
|
424
|
+
}
|
|
425
|
+
|
|
326
426
|
/** Returns whether the named output format can be safely concatenated without re-multiplexing. */
|
|
327
427
|
export function resolveMergeAudioFormat(format: string): MergeAudioFormat | undefined {
|
|
328
428
|
if (isWavFormat(format)) return "wav";
|
|
@@ -382,7 +482,7 @@ function closeSpeechResources(speechConfig: SpeechSDK.SpeechConfig, synthesizer:
|
|
|
382
482
|
|
|
383
483
|
const ticksToMilliseconds = (ticks: number): number => Math.max(0, ticks) / 10_000;
|
|
384
484
|
|
|
385
|
-
|
|
485
|
+
async function synthesizeSsmlOnce(ssml: string, config: TtsConfig): Promise<SsmlSynthesisResult> {
|
|
386
486
|
if (config.signal?.aborted) {
|
|
387
487
|
throw new SynthesisCancelledError();
|
|
388
488
|
}
|
|
@@ -540,6 +640,13 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
540
640
|
rejectWithError(err);
|
|
541
641
|
return;
|
|
542
642
|
}
|
|
643
|
+
let audioSpec: AudioSpecification;
|
|
644
|
+
try {
|
|
645
|
+
audioSpec = inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT);
|
|
646
|
+
} catch (error) {
|
|
647
|
+
rejectWithError(error);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
543
650
|
settled = true;
|
|
544
651
|
cleanup();
|
|
545
652
|
closeResources();
|
|
@@ -579,8 +686,8 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
579
686
|
resolve({
|
|
580
687
|
audioData: result.audioData,
|
|
581
688
|
durationMs,
|
|
582
|
-
audioSpec
|
|
583
|
-
mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
|
|
689
|
+
audioSpec,
|
|
690
|
+
mimeType: config.outputMimeType ?? resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
|
|
584
691
|
...(config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {}),
|
|
585
692
|
...(requestId ? { requestId } : {}),
|
|
586
693
|
...(sourceBoundaries.length > 0
|
|
@@ -596,7 +703,7 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
596
703
|
abortHandler = () => rejectWithError(new SynthesisCancelledError());
|
|
597
704
|
config.signal.addEventListener("abort", abortHandler, { once: true });
|
|
598
705
|
}
|
|
599
|
-
const timeoutMs = config.timeouts?.perChunkMs ?? config.timeoutMs;
|
|
706
|
+
const timeoutMs = config.timeouts?.perChunkMs ?? config.timeouts?.totalJobMs ?? config.timeoutMs;
|
|
600
707
|
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
601
708
|
timeout = setTimeout(
|
|
602
709
|
() => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
|
|
@@ -659,6 +766,7 @@ async function synthesizeWithRetry(
|
|
|
659
766
|
config: TtsConfig,
|
|
660
767
|
retryOptions: RetryOptions | undefined,
|
|
661
768
|
onRetry: (retryAttempt: number, nextRetryDelayMs: number) => void,
|
|
769
|
+
deadlineAtMs?: number,
|
|
662
770
|
): Promise<SsmlSynthesisResult> {
|
|
663
771
|
const options = retryOptions
|
|
664
772
|
? {
|
|
@@ -672,7 +780,7 @@ async function synthesizeWithRetry(
|
|
|
672
780
|
while (true) {
|
|
673
781
|
if (config.signal?.aborted) throw new SynthesisCancelledError();
|
|
674
782
|
try {
|
|
675
|
-
return await
|
|
783
|
+
return await synthesizeSsmlOnce(ssml, config);
|
|
676
784
|
} catch (error) {
|
|
677
785
|
if (
|
|
678
786
|
!options ||
|
|
@@ -682,12 +790,31 @@ async function synthesizeWithRetry(
|
|
|
682
790
|
throw error;
|
|
683
791
|
attempt += 1;
|
|
684
792
|
const delayMs = retryDelay(options, attempt, error);
|
|
793
|
+
const remainingMs = deadlineAtMs === undefined ? undefined : Math.max(0, deadlineAtMs - Date.now());
|
|
794
|
+
if (
|
|
795
|
+
getRetryAfterDelayMs(error) !== undefined &&
|
|
796
|
+
(delayMs > options.maxDelayMs || (remainingMs !== undefined && delayMs > remainingMs))
|
|
797
|
+
) {
|
|
798
|
+
throw new SynthesisTimeoutError(
|
|
799
|
+
remainingMs === undefined
|
|
800
|
+
? `Retry-After exceeded maxDelayMs (${options.maxDelayMs} ms).`
|
|
801
|
+
: `Retry-After exceeded the remaining total job timeout (${remainingMs} ms).`,
|
|
802
|
+
);
|
|
803
|
+
}
|
|
685
804
|
onRetry(attempt, delayMs);
|
|
686
|
-
await waitForRetry(delayMs, config.signal);
|
|
805
|
+
await waitForRetry(Math.min(delayMs, remainingMs ?? delayMs), config.signal);
|
|
687
806
|
}
|
|
688
807
|
}
|
|
689
808
|
}
|
|
690
809
|
|
|
810
|
+
/** Synthesizes one SSML document, optionally retrying transient failures within the job deadline. */
|
|
811
|
+
export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<SsmlSynthesisResult> {
|
|
812
|
+
const totalJobMs = config.timeouts?.totalJobMs;
|
|
813
|
+
const deadlineAtMs = totalJobMs !== undefined && totalJobMs > 0 ? Date.now() + totalJobMs : undefined;
|
|
814
|
+
if (!config.retryOptions) return synthesizeSsmlOnce(ssml, config);
|
|
815
|
+
return synthesizeWithRetry(ssml, config, config.retryOptions, () => undefined, deadlineAtMs);
|
|
816
|
+
}
|
|
817
|
+
|
|
691
818
|
interface AbortScope {
|
|
692
819
|
signal: AbortSignal;
|
|
693
820
|
timedOut: () => boolean;
|
|
@@ -725,10 +852,11 @@ async function synthesizeChunkWithTimeout(
|
|
|
725
852
|
retryOptions: RetryOptions | undefined,
|
|
726
853
|
timeoutMs: number | undefined,
|
|
727
854
|
onRetry: (retryAttempt: number, nextRetryDelayMs: number) => void,
|
|
855
|
+
deadlineAtMs?: number,
|
|
728
856
|
): Promise<SsmlSynthesisResult> {
|
|
729
857
|
const scope = createAbortScope(config.signal, timeoutMs);
|
|
730
858
|
try {
|
|
731
|
-
return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry);
|
|
859
|
+
return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry, deadlineAtMs);
|
|
732
860
|
} catch (error) {
|
|
733
861
|
if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
|
|
734
862
|
throw error;
|
|
@@ -742,21 +870,43 @@ export async function synthesizeSsmlChunks(
|
|
|
742
870
|
chunks: readonly (SsmlSynthesisChunk | string)[],
|
|
743
871
|
config: TtsConfig,
|
|
744
872
|
): Promise<SsmlSynthesisResult> {
|
|
745
|
-
const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
|
|
746
873
|
const totalChunks = chunks.length;
|
|
874
|
+
const inputs = chunks.map((chunk) => (typeof chunk === "string" ? { ssml: chunk } : chunk));
|
|
875
|
+
const fingerprints = inputs.map((chunk) =>
|
|
876
|
+
computeChunkFingerprint(chunk.ssml, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
|
|
877
|
+
);
|
|
878
|
+
const results: Array<SsmlSynthesisResult | undefined> = new Array(totalChunks);
|
|
747
879
|
const cachedChunks = new Map((config.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
|
|
880
|
+
const invalidCachedIndices = new Set<number>();
|
|
881
|
+
const chunkStates: ChunkExecutionState[] = inputs.map((_chunk, chunkIndex) => ({
|
|
882
|
+
chunkIndex,
|
|
883
|
+
status: "pending",
|
|
884
|
+
canResume: true,
|
|
885
|
+
}));
|
|
748
886
|
for (const [index, cached] of cachedChunks) {
|
|
749
|
-
if (index
|
|
887
|
+
if (index < 0 || index >= totalChunks) continue;
|
|
888
|
+
const isValid = config.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index];
|
|
889
|
+
if (isValid) {
|
|
890
|
+
results[index] = { ...cached };
|
|
891
|
+
chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
|
|
892
|
+
} else {
|
|
893
|
+
invalidCachedIndices.add(index);
|
|
894
|
+
}
|
|
750
895
|
}
|
|
751
896
|
const requestedIndices = config.resumeChunkIndices
|
|
752
897
|
? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks))
|
|
753
898
|
: undefined;
|
|
754
899
|
const shouldSynthesize = (index: number): boolean =>
|
|
755
|
-
!cachedChunks.has(index)
|
|
900
|
+
(!cachedChunks.has(index) || invalidCachedIndices.has(index)) &&
|
|
901
|
+
(requestedIndices === undefined || requestedIndices.has(index) || invalidCachedIndices.has(index));
|
|
902
|
+
const jobStartedAt = Date.now();
|
|
903
|
+
const jobDeadlineAt =
|
|
904
|
+
config.timeouts?.totalJobMs !== undefined && config.timeouts.totalJobMs > 0
|
|
905
|
+
? jobStartedAt + config.timeouts.totalJobMs
|
|
906
|
+
: undefined;
|
|
756
907
|
const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
|
|
757
908
|
const report = (event: SynthesisProgressEvent): void => config.onProgress?.(event);
|
|
758
|
-
for (const [index,
|
|
759
|
-
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
909
|
+
for (const [index, input] of inputs.entries()) {
|
|
760
910
|
report({
|
|
761
911
|
currentChunk: index,
|
|
762
912
|
totalChunks,
|
|
@@ -778,8 +928,7 @@ export async function synthesizeSsmlChunks(
|
|
|
778
928
|
if (index >= chunks.length) return;
|
|
779
929
|
if (!shouldSynthesize(index)) continue;
|
|
780
930
|
if (firstError && config.cancelOnFailure !== false) return;
|
|
781
|
-
const
|
|
782
|
-
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
931
|
+
const input = inputs[index];
|
|
783
932
|
report({
|
|
784
933
|
currentChunk: completed,
|
|
785
934
|
totalChunks,
|
|
@@ -820,8 +969,10 @@ export async function synthesizeSsmlChunks(
|
|
|
820
969
|
nextRetryDelayMs,
|
|
821
970
|
isRetrying: true,
|
|
822
971
|
}),
|
|
972
|
+
jobDeadlineAt,
|
|
823
973
|
);
|
|
824
974
|
results[index] = result;
|
|
975
|
+
chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result };
|
|
825
976
|
completed += 1;
|
|
826
977
|
report({
|
|
827
978
|
currentChunk: completed,
|
|
@@ -833,7 +984,18 @@ export async function synthesizeSsmlChunks(
|
|
|
833
984
|
durationMs: Date.now() - startedAt,
|
|
834
985
|
});
|
|
835
986
|
} catch (error) {
|
|
836
|
-
|
|
987
|
+
const wasCancelled = firstError !== undefined || (scope.signal.aborted && !scope.timedOut());
|
|
988
|
+
firstError ??= scope.timedOut()
|
|
989
|
+
? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`)
|
|
990
|
+
: error;
|
|
991
|
+
if (!wasCancelled) failedIndices.add(index);
|
|
992
|
+
chunkStates[index] = {
|
|
993
|
+
chunkIndex: index,
|
|
994
|
+
status: wasCancelled ? "cancelled" : "failed",
|
|
995
|
+
isOriginalFailure: !wasCancelled,
|
|
996
|
+
canResume: true,
|
|
997
|
+
error: error as ChunkExecutionState["error"],
|
|
998
|
+
};
|
|
837
999
|
report({
|
|
838
1000
|
currentChunk: completed,
|
|
839
1001
|
totalChunks,
|
|
@@ -844,9 +1006,6 @@ export async function synthesizeSsmlChunks(
|
|
|
844
1006
|
durationMs: Date.now() - startedAt,
|
|
845
1007
|
error,
|
|
846
1008
|
});
|
|
847
|
-
firstError ??= scope.timedOut()
|
|
848
|
-
? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`)
|
|
849
|
-
: error;
|
|
850
1009
|
if (config.cancelOnFailure !== false) scope.abort();
|
|
851
1010
|
return;
|
|
852
1011
|
}
|
|
@@ -864,11 +1023,29 @@ export async function synthesizeSsmlChunks(
|
|
|
864
1023
|
postMergeValidator: config.postMergeValidator,
|
|
865
1024
|
});
|
|
866
1025
|
} catch (error) {
|
|
1026
|
+
if (firstError && config.cancelOnFailure !== false) {
|
|
1027
|
+
for (const [chunkIndex, state] of chunkStates.entries()) {
|
|
1028
|
+
if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
|
|
1029
|
+
chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
const synthesizedChunks = results.flatMap((result, chunkIndex) =>
|
|
1034
|
+
result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : [],
|
|
1035
|
+
);
|
|
867
1036
|
const partial = {
|
|
868
|
-
synthesizedChunks
|
|
869
|
-
completedChunks:
|
|
870
|
-
pendingChunkIndices:
|
|
1037
|
+
synthesizedChunks,
|
|
1038
|
+
completedChunks: synthesizedChunks,
|
|
1039
|
+
pendingChunkIndices: chunkStates.flatMap((state) =>
|
|
1040
|
+
state.status === "pending" || state.status === "cancelled" || state.status === "failed"
|
|
1041
|
+
? [state.chunkIndex]
|
|
1042
|
+
: [],
|
|
1043
|
+
),
|
|
871
1044
|
failedChunkIndices: [...failedIndices],
|
|
1045
|
+
cancelledChunkIndices: chunkStates
|
|
1046
|
+
.filter((state) => state.status === "cancelled")
|
|
1047
|
+
.map((state) => state.chunkIndex),
|
|
1048
|
+
chunkStates,
|
|
872
1049
|
totalChunks,
|
|
873
1050
|
};
|
|
874
1051
|
if (error && typeof error === "object") (error as { partialResult?: unknown }).partialResult = partial;
|
|
@@ -998,19 +1175,15 @@ export function mergeSynthesisResults(
|
|
|
998
1175
|
)
|
|
999
1176
|
.then((merged) => {
|
|
1000
1177
|
if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
|
|
1001
|
-
if (
|
|
1002
|
-
!(merged instanceof ArrayBuffer) ||
|
|
1003
|
-
(buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
|
|
1004
|
-
)
|
|
1005
|
-
throw new MergeError("The custom audio merger returned an invalid audio buffer.");
|
|
1006
1178
|
if (signal.aborted) throw new SynthesisCancelledError();
|
|
1007
|
-
const
|
|
1008
|
-
results,
|
|
1179
|
+
const mergedSpec = validateMergedAudioBuffer(
|
|
1009
1180
|
merged,
|
|
1010
1181
|
format,
|
|
1011
|
-
|
|
1012
|
-
|
|
1182
|
+
buffers,
|
|
1183
|
+
inputSpecs,
|
|
1184
|
+
resolvedOptions.outputMimeType ?? resolveMimeType(format),
|
|
1013
1185
|
);
|
|
1186
|
+
const result = createMergedResult(results, merged, format, mergedSpec, resolvedOptions.outputMimeType);
|
|
1014
1187
|
return Promise.resolve(
|
|
1015
1188
|
resolvedOptions.postMergeValidator?.(result, {
|
|
1016
1189
|
format,
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { SsmlSourceMarker, SsmlSourceTextSegment, SsmlTextRange } from "@ssml-builder-js/ssml-core";
|
|
2
2
|
import type { AzureTtsOutputFormat } from "./outputFormats.ts";
|
|
3
|
+
import type { AzureTtsError } from "./errors.ts";
|
|
4
|
+
import type { SsmlValidationError } from "./safe.ts";
|
|
3
5
|
|
|
4
6
|
export interface TtsConfig {
|
|
5
7
|
signal?: AbortSignal;
|
|
@@ -27,6 +29,7 @@ export interface TtsConfig {
|
|
|
27
29
|
customMerger?: CustomAudioMerger;
|
|
28
30
|
outputMimeType?: string;
|
|
29
31
|
postMergeValidator?: PostMergeValidator;
|
|
32
|
+
resumeValidation?: ResumeValidationMode;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
export type MappingStatus = "exact" | "fallback" | "unmapped";
|
|
@@ -34,7 +37,7 @@ export type MappingStatus = "exact" | "fallback" | "unmapped";
|
|
|
34
37
|
export interface AudioSpecification {
|
|
35
38
|
format: string;
|
|
36
39
|
mimeType: string;
|
|
37
|
-
codec: "pcm" | "mp3" | "opus" | "silk" | "unknown";
|
|
40
|
+
codec: "pcm" | "mulaw" | "alaw" | "siren" | "mp3" | "opus" | "silk" | "unknown";
|
|
38
41
|
sampleRate: number;
|
|
39
42
|
channels: number;
|
|
40
43
|
bitrate?: number;
|
|
@@ -44,6 +47,8 @@ export interface AudioSpecification {
|
|
|
44
47
|
isCompressed: boolean;
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
export type ResumeValidationMode = "strict" | "disabled";
|
|
51
|
+
|
|
47
52
|
export interface SynthesisTimeouts {
|
|
48
53
|
urlValidationMs?: number;
|
|
49
54
|
perChunkMs?: number;
|
|
@@ -148,6 +153,7 @@ export interface SynthesizeChunksOptions {
|
|
|
148
153
|
customMerger?: CustomAudioMerger;
|
|
149
154
|
outputMimeType?: string;
|
|
150
155
|
postMergeValidator?: PostMergeValidator;
|
|
156
|
+
resumeValidation?: ResumeValidationMode;
|
|
151
157
|
}
|
|
152
158
|
|
|
153
159
|
export interface CustomMergerContext {
|
|
@@ -169,6 +175,19 @@ export type PostMergeValidator = (
|
|
|
169
175
|
|
|
170
176
|
export interface SynthesizedChunk extends SsmlSynthesisResult {
|
|
171
177
|
chunkIndex: number;
|
|
178
|
+
/** Fingerprint of the SSML and synthesis settings used to create this chunk. */
|
|
179
|
+
fingerprint: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export type ChunkExecutionStatus = "succeeded" | "failed" | "cancelled" | "pending";
|
|
183
|
+
|
|
184
|
+
export interface ChunkExecutionState {
|
|
185
|
+
chunkIndex: number;
|
|
186
|
+
status: ChunkExecutionStatus;
|
|
187
|
+
error?: AzureTtsError | SsmlValidationError;
|
|
188
|
+
isOriginalFailure?: boolean;
|
|
189
|
+
canResume: boolean;
|
|
190
|
+
result?: SsmlSynthesisResult;
|
|
172
191
|
}
|
|
173
192
|
|
|
174
193
|
export interface PartialChunkSynthesisResult {
|
|
@@ -176,6 +195,8 @@ export interface PartialChunkSynthesisResult {
|
|
|
176
195
|
completedChunks: readonly SynthesizedChunk[];
|
|
177
196
|
pendingChunkIndices: readonly number[];
|
|
178
197
|
failedChunkIndices: readonly number[];
|
|
198
|
+
cancelledChunkIndices: readonly number[];
|
|
199
|
+
chunkStates: readonly ChunkExecutionState[];
|
|
179
200
|
totalChunks: number;
|
|
180
201
|
}
|
|
181
202
|
|
|
@@ -216,4 +237,9 @@ export interface AzureTtsClientOptions {
|
|
|
216
237
|
onProgress?: (event: SynthesisProgressEvent) => void;
|
|
217
238
|
concurrency?: number;
|
|
218
239
|
retryOptions?: RetryOptions;
|
|
240
|
+
cancelOnFailure?: boolean;
|
|
241
|
+
customMerger?: CustomAudioMerger;
|
|
242
|
+
outputMimeType?: string;
|
|
243
|
+
postMergeValidator?: PostMergeValidator;
|
|
244
|
+
resumeValidation?: ResumeValidationMode;
|
|
219
245
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { AzureTtsError, inspectAudioSpecification, synthesizeSsmlChunksSafe } from "../src/index.ts";
|
|
4
|
+
|
|
5
|
+
const validSsml = (text: string) =>
|
|
6
|
+
`<speak version="1.0" xml:lang="en-US"><voice name="en-US-JennyNeural"><prosody rate="+5%" pitch="+2st">${text}</prosody></voice></speak>`;
|
|
7
|
+
|
|
8
|
+
test("invalidates a resume chunk when its SSML fingerprint changes", async () => {
|
|
9
|
+
let calls = 0;
|
|
10
|
+
let failSecondChunk = true;
|
|
11
|
+
const client = {
|
|
12
|
+
synthesizeSsml: async (ssml: string) => {
|
|
13
|
+
calls += 1;
|
|
14
|
+
if (ssml.includes("two") && failSecondChunk) {
|
|
15
|
+
failSecondChunk = false;
|
|
16
|
+
throw new Error("temporary failure");
|
|
17
|
+
}
|
|
18
|
+
return { audioData: Uint8Array.of(calls).buffer, durationMs: 1 };
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
const first = await synthesizeSsmlChunksSafe(client, [validSsml("one"), validSsml("two")], {
|
|
22
|
+
concurrency: 1,
|
|
23
|
+
});
|
|
24
|
+
assert.equal(first.ok, false);
|
|
25
|
+
assert.ok(first.partialResult);
|
|
26
|
+
if (first.ok || !first.partialResult) return;
|
|
27
|
+
|
|
28
|
+
const resumed = await synthesizeSsmlChunksSafe(client, [validSsml("changed"), validSsml("two")], {
|
|
29
|
+
concurrency: 1,
|
|
30
|
+
resumeChunks: first.partialResult.synthesizedChunks,
|
|
31
|
+
resumeChunkIndices: first.partialResult.pendingChunkIndices,
|
|
32
|
+
});
|
|
33
|
+
assert.equal(resumed.ok, true);
|
|
34
|
+
assert.equal(calls, 4);
|
|
35
|
+
assert.match(first.partialResult.synthesizedChunks[0]?.fingerprint ?? "", /^fnv1a64-/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("separates the original failure from chained cancellations", async () => {
|
|
39
|
+
const result = await synthesizeSsmlChunksSafe(
|
|
40
|
+
{
|
|
41
|
+
synthesizeSsml: async () => {
|
|
42
|
+
throw new Error("direct failure");
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
[validSsml("fail"), validSsml("cancelled")],
|
|
46
|
+
{ concurrency: 1 },
|
|
47
|
+
);
|
|
48
|
+
assert.equal(result.ok, false);
|
|
49
|
+
if (!result.ok) {
|
|
50
|
+
assert.deepEqual(result.partialResult?.failedChunkIndices, [0]);
|
|
51
|
+
assert.deepEqual(result.partialResult?.cancelledChunkIndices, [1]);
|
|
52
|
+
assert.equal(result.partialResult?.chunkStates[0]?.status, "failed");
|
|
53
|
+
assert.equal(result.partialResult?.chunkStates[0]?.isOriginalFailure, true);
|
|
54
|
+
assert.equal(result.partialResult?.chunkStates[1]?.status, "cancelled");
|
|
55
|
+
assert.equal(result.partialResult?.chunkStates[1]?.isOriginalFailure, false);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("does not wait for Retry-After beyond the retry budget", async () => {
|
|
60
|
+
let calls = 0;
|
|
61
|
+
const startedAt = Date.now();
|
|
62
|
+
const result = await synthesizeSsmlChunksSafe(
|
|
63
|
+
{
|
|
64
|
+
synthesizeSsml: async () => {
|
|
65
|
+
calls += 1;
|
|
66
|
+
throw new AzureTtsError(429, "Too Many Requests", "", null, { "retry-after": "10" });
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
[validSsml("retry")],
|
|
70
|
+
{ retryOptions: { maxRetries: 2, initialDelayMs: 1, maxDelayMs: 5 } },
|
|
71
|
+
);
|
|
72
|
+
assert.equal(result.ok, false);
|
|
73
|
+
assert.equal(calls, 1);
|
|
74
|
+
assert.ok(Date.now() - startedAt < 100);
|
|
75
|
+
if (!result.ok) assert.equal(result.error.kind, "timeout");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("maps headerless RAW formats to strict codec specifications", () => {
|
|
79
|
+
const mulaw = inspectAudioSpecification(new ArrayBuffer(8), "raw-8khz-8bit-mono-mulaw");
|
|
80
|
+
assert.deepEqual(
|
|
81
|
+
{
|
|
82
|
+
sampleRate: mulaw.sampleRate,
|
|
83
|
+
channels: mulaw.channels,
|
|
84
|
+
bitDepth: mulaw.bitDepth,
|
|
85
|
+
codec: mulaw.codec,
|
|
86
|
+
mimeType: mulaw.mimeType,
|
|
87
|
+
},
|
|
88
|
+
{ sampleRate: 8_000, channels: 1, bitDepth: 8, codec: "mulaw", mimeType: "audio/basic" },
|
|
89
|
+
);
|
|
90
|
+
assert.throws(() => inspectAudioSpecification(new ArrayBuffer(1), "raw-16khz-16bit-mono-pcm"));
|
|
91
|
+
});
|