@ssml-builder-js/azure-tts-client 2.16.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 +21 -0
- package/dist/index.d.mts +223 -127
- package/dist/index.d.ts +223 -127
- package/dist/index.js +552 -98
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +549 -98
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +29 -4
- package/src/errors.ts +44 -2
- package/src/index.ts +19 -2
- package/src/outputFormats.ts +3 -0
- package/src/safe.ts +280 -42
- package/src/synthesis.ts +380 -57
- package/src/types.ts +85 -1
- package/src/voiceCatalog.ts +4 -0
- package/test/v217-pipeline.test.ts +125 -0
- package/test/v218-pipeline.test.ts +91 -0
package/src/synthesis.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
SynthesisTimeoutError,
|
|
9
9
|
toSynthesisError,
|
|
10
10
|
UnsupportedMergeFormatError,
|
|
11
|
+
getRetryAfterDelayMs,
|
|
11
12
|
} from "./errors.ts";
|
|
12
13
|
import { DEFAULT_OUTPUT_FORMAT, resolveMimeType, type AzureTtsOutputFormat } from "./outputFormats.ts";
|
|
13
14
|
import { createSpeechConfig } from "./speechConfig.ts";
|
|
@@ -19,6 +20,9 @@ import type {
|
|
|
19
20
|
SynthesisProgressEvent,
|
|
20
21
|
TtsConfig,
|
|
21
22
|
RetryOptions,
|
|
23
|
+
CustomAudioMerger,
|
|
24
|
+
PostMergeValidator,
|
|
25
|
+
ChunkExecutionState,
|
|
22
26
|
} from "./types.ts";
|
|
23
27
|
|
|
24
28
|
export type MergeAudioFormat = "wav" | "mp3" | "raw";
|
|
@@ -31,15 +35,36 @@ export interface MergeAudioOptions {
|
|
|
31
35
|
|
|
32
36
|
export type InputAudioSpecs = AudioSpecification[];
|
|
33
37
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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")}`;
|
|
39
63
|
}
|
|
40
64
|
|
|
41
65
|
export interface MergeSynthesisOptions extends MergeAudioOptions {
|
|
42
|
-
customMerger?:
|
|
66
|
+
customMerger?: CustomAudioMerger;
|
|
67
|
+
postMergeValidator?: PostMergeValidator;
|
|
43
68
|
}
|
|
44
69
|
|
|
45
70
|
type AsyncMergeSynthesisOptions = MergeSynthesisOptions & {
|
|
@@ -99,9 +124,11 @@ function parseWav(buffer: ArrayBuffer): ParsedWav {
|
|
|
99
124
|
return { chunks, data, format };
|
|
100
125
|
}
|
|
101
126
|
|
|
102
|
-
function
|
|
103
|
-
const match =
|
|
104
|
-
|
|
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;
|
|
105
132
|
}
|
|
106
133
|
|
|
107
134
|
function formatChannels(format: string, fallback: number): number {
|
|
@@ -111,7 +138,7 @@ function formatChannels(format: string, fallback: number): number {
|
|
|
111
138
|
}
|
|
112
139
|
|
|
113
140
|
function formatAudioSpecification(format: string): AudioSpecification {
|
|
114
|
-
const sampleRate =
|
|
141
|
+
const sampleRate = formatSampleRate(format);
|
|
115
142
|
const channels = formatChannels(format, 0);
|
|
116
143
|
const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
|
|
117
144
|
const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1000 : undefined;
|
|
@@ -121,9 +148,27 @@ function formatAudioSpecification(format: string): AudioSpecification {
|
|
|
121
148
|
? "opus"
|
|
122
149
|
: /silk/i.test(format)
|
|
123
150
|
? "silk"
|
|
124
|
-
: /
|
|
125
|
-
? "
|
|
126
|
-
:
|
|
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";
|
|
160
|
+
const bitDepthMatch = /(\d+)bit/i.exec(format);
|
|
161
|
+
const container = /(?:wav|wave|riff)/i.test(format)
|
|
162
|
+
? "riff-wave"
|
|
163
|
+
: /mp3|mpeg/i.test(format)
|
|
164
|
+
? "mp3-raw"
|
|
165
|
+
: /ogg/i.test(format)
|
|
166
|
+
? "ogg"
|
|
167
|
+
: /webm/i.test(format)
|
|
168
|
+
? "webm"
|
|
169
|
+
: /raw/i.test(format)
|
|
170
|
+
? "raw"
|
|
171
|
+
: undefined;
|
|
127
172
|
return {
|
|
128
173
|
format,
|
|
129
174
|
mimeType: resolveMimeType(format),
|
|
@@ -131,7 +176,10 @@ function formatAudioSpecification(format: string): AudioSpecification {
|
|
|
131
176
|
sampleRate,
|
|
132
177
|
channels,
|
|
133
178
|
...(bitrate ? { bitrate } : {}),
|
|
134
|
-
|
|
179
|
+
...(bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {}),
|
|
180
|
+
...(container ? { container } : {}),
|
|
181
|
+
isVbr: /vbr/i.test(format),
|
|
182
|
+
isCompressed: codec !== "pcm" && codec !== "unknown",
|
|
135
183
|
};
|
|
136
184
|
}
|
|
137
185
|
|
|
@@ -167,6 +215,8 @@ function parseMp3Specification(buffer: ArrayBuffer, format: string): AudioSpecif
|
|
|
167
215
|
sampleRate,
|
|
168
216
|
channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
|
|
169
217
|
bitrate: bitrateKbps * 1000,
|
|
218
|
+
container: "mp3-raw",
|
|
219
|
+
isVbr: false,
|
|
170
220
|
isCompressed: true,
|
|
171
221
|
};
|
|
172
222
|
}
|
|
@@ -183,18 +233,47 @@ export function inspectAudioSpecification(buffer: ArrayBuffer, format: string):
|
|
|
183
233
|
const channels = view.getUint16(2, true);
|
|
184
234
|
const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
|
|
185
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";
|
|
186
247
|
return {
|
|
187
248
|
format,
|
|
188
249
|
mimeType: "audio/wav",
|
|
189
|
-
codec
|
|
250
|
+
codec,
|
|
190
251
|
sampleRate,
|
|
191
252
|
channels,
|
|
192
253
|
...(sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {}),
|
|
193
|
-
|
|
254
|
+
bitDepth: bitsPerSample,
|
|
255
|
+
container: "riff-wave",
|
|
256
|
+
isVbr: false,
|
|
257
|
+
isCompressed: codec !== "pcm" && codec !== "unknown",
|
|
194
258
|
};
|
|
195
259
|
}
|
|
196
260
|
if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
|
|
197
|
-
|
|
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
|
+
}
|
|
198
277
|
}
|
|
199
278
|
|
|
200
279
|
function validateAudioSpecifications(specs: readonly AudioSpecification[]): void {
|
|
@@ -204,7 +283,11 @@ function validateAudioSpecifications(specs: readonly AudioSpecification[]): void
|
|
|
204
283
|
(spec) =>
|
|
205
284
|
spec.sampleRate !== first.sampleRate ||
|
|
206
285
|
spec.channels !== first.channels ||
|
|
207
|
-
|
|
286
|
+
spec.codec !== first.codec ||
|
|
287
|
+
(first.bitrate !== undefined && spec.bitrate !== undefined && spec.bitrate !== first.bitrate) ||
|
|
288
|
+
(first.bitDepth !== undefined && spec.bitDepth !== undefined && spec.bitDepth !== first.bitDepth) ||
|
|
289
|
+
(first.container !== undefined && spec.container !== undefined && spec.container !== first.container) ||
|
|
290
|
+
(first.isVbr !== undefined && spec.isVbr !== undefined && spec.isVbr !== first.isVbr),
|
|
208
291
|
);
|
|
209
292
|
if (mismatch)
|
|
210
293
|
throw new AudioFormatMismatchError(
|
|
@@ -303,6 +386,43 @@ function isRawFormat(format: string): boolean {
|
|
|
303
386
|
return /^raw(?:-|$)/i.test(format);
|
|
304
387
|
}
|
|
305
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
|
+
|
|
306
426
|
/** Returns whether the named output format can be safely concatenated without re-multiplexing. */
|
|
307
427
|
export function resolveMergeAudioFormat(format: string): MergeAudioFormat | undefined {
|
|
308
428
|
if (isWavFormat(format)) return "wav";
|
|
@@ -362,7 +482,7 @@ function closeSpeechResources(speechConfig: SpeechSDK.SpeechConfig, synthesizer:
|
|
|
362
482
|
|
|
363
483
|
const ticksToMilliseconds = (ticks: number): number => Math.max(0, ticks) / 10_000;
|
|
364
484
|
|
|
365
|
-
|
|
485
|
+
async function synthesizeSsmlOnce(ssml: string, config: TtsConfig): Promise<SsmlSynthesisResult> {
|
|
366
486
|
if (config.signal?.aborted) {
|
|
367
487
|
throw new SynthesisCancelledError();
|
|
368
488
|
}
|
|
@@ -448,9 +568,7 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
448
568
|
};
|
|
449
569
|
}
|
|
450
570
|
if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) {
|
|
451
|
-
|
|
452
|
-
Object.defineProperty(unmapped, "mappingStatus", { value: "unmapped", enumerable: false });
|
|
453
|
-
return unmapped;
|
|
571
|
+
return { mappingStatus: "unmapped" };
|
|
454
572
|
}
|
|
455
573
|
const value = text ?? "";
|
|
456
574
|
let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? (offsetHint as number) : -1;
|
|
@@ -522,6 +640,13 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
522
640
|
rejectWithError(err);
|
|
523
641
|
return;
|
|
524
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
|
+
}
|
|
525
650
|
settled = true;
|
|
526
651
|
cleanup();
|
|
527
652
|
closeResources();
|
|
@@ -546,8 +671,13 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
546
671
|
...(config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {}),
|
|
547
672
|
...(requestId ? { requestId } : {}),
|
|
548
673
|
} as T;
|
|
549
|
-
if (event.mappingStatus === "unmapped")
|
|
674
|
+
if (event.mappingStatus === "unmapped") {
|
|
550
675
|
Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
|
|
676
|
+
Object.defineProperty(mapped, "toJSON", {
|
|
677
|
+
value: () => ({ ...mapped, mappingStatus: "unmapped" }),
|
|
678
|
+
enumerable: false,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
551
681
|
return mapped;
|
|
552
682
|
};
|
|
553
683
|
const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
|
|
@@ -556,8 +686,8 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
556
686
|
resolve({
|
|
557
687
|
audioData: result.audioData,
|
|
558
688
|
durationMs,
|
|
559
|
-
audioSpec
|
|
560
|
-
mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
|
|
689
|
+
audioSpec,
|
|
690
|
+
mimeType: config.outputMimeType ?? resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
|
|
561
691
|
...(config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {}),
|
|
562
692
|
...(requestId ? { requestId } : {}),
|
|
563
693
|
...(sourceBoundaries.length > 0
|
|
@@ -573,10 +703,11 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
573
703
|
abortHandler = () => rejectWithError(new SynthesisCancelledError());
|
|
574
704
|
config.signal.addEventListener("abort", abortHandler, { once: true });
|
|
575
705
|
}
|
|
576
|
-
|
|
706
|
+
const timeoutMs = config.timeouts?.perChunkMs ?? config.timeouts?.totalJobMs ?? config.timeoutMs;
|
|
707
|
+
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
577
708
|
timeout = setTimeout(
|
|
578
|
-
() => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${
|
|
579
|
-
|
|
709
|
+
() => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
|
|
710
|
+
timeoutMs,
|
|
580
711
|
);
|
|
581
712
|
}
|
|
582
713
|
synthesizer.speakSsmlAsync(ssml, cb, rejectWithError);
|
|
@@ -597,7 +728,9 @@ function isRetryableSynthesisError(error: unknown): boolean {
|
|
|
597
728
|
return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
|
|
598
729
|
}
|
|
599
730
|
|
|
600
|
-
function retryDelay(options: RetryOptions, retryAttempt: number): number {
|
|
731
|
+
function retryDelay(options: RetryOptions, retryAttempt: number, error?: unknown): number {
|
|
732
|
+
const retryAfterMs = getRetryAfterDelayMs(error);
|
|
733
|
+
if (retryAfterMs !== undefined) return retryAfterMs;
|
|
601
734
|
const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
|
|
602
735
|
return Math.floor(Math.random() * (base + 1));
|
|
603
736
|
}
|
|
@@ -633,39 +766,147 @@ async function synthesizeWithRetry(
|
|
|
633
766
|
config: TtsConfig,
|
|
634
767
|
retryOptions: RetryOptions | undefined,
|
|
635
768
|
onRetry: (retryAttempt: number, nextRetryDelayMs: number) => void,
|
|
769
|
+
deadlineAtMs?: number,
|
|
636
770
|
): Promise<SsmlSynthesisResult> {
|
|
637
771
|
const options = retryOptions
|
|
638
772
|
? {
|
|
639
773
|
maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
|
|
640
774
|
initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
|
|
641
775
|
maxDelayMs: Math.max(0, retryOptions.maxDelayMs),
|
|
776
|
+
shouldRetry: retryOptions.shouldRetry,
|
|
642
777
|
}
|
|
643
778
|
: undefined;
|
|
644
779
|
let attempt = 0;
|
|
645
780
|
while (true) {
|
|
646
781
|
if (config.signal?.aborted) throw new SynthesisCancelledError();
|
|
647
782
|
try {
|
|
648
|
-
return await
|
|
783
|
+
return await synthesizeSsmlOnce(ssml, config);
|
|
649
784
|
} catch (error) {
|
|
650
|
-
if (
|
|
785
|
+
if (
|
|
786
|
+
!options ||
|
|
787
|
+
attempt >= options.maxRetries ||
|
|
788
|
+
!(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error))
|
|
789
|
+
)
|
|
790
|
+
throw error;
|
|
651
791
|
attempt += 1;
|
|
652
|
-
const delayMs = retryDelay(options, attempt);
|
|
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
|
+
}
|
|
653
804
|
onRetry(attempt, delayMs);
|
|
654
|
-
await waitForRetry(delayMs, config.signal);
|
|
805
|
+
await waitForRetry(Math.min(delayMs, remainingMs ?? delayMs), config.signal);
|
|
655
806
|
}
|
|
656
807
|
}
|
|
657
808
|
}
|
|
658
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
|
+
|
|
818
|
+
interface AbortScope {
|
|
819
|
+
signal: AbortSignal;
|
|
820
|
+
timedOut: () => boolean;
|
|
821
|
+
dispose: () => void;
|
|
822
|
+
abort: () => void;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function createAbortScope(parent: AbortSignal | undefined, timeoutMs: number | undefined): AbortScope {
|
|
826
|
+
const controller = new AbortController();
|
|
827
|
+
let didTimeout = false;
|
|
828
|
+
const onAbort = () => controller.abort();
|
|
829
|
+
if (parent?.aborted) controller.abort();
|
|
830
|
+
parent?.addEventListener("abort", onAbort, { once: true });
|
|
831
|
+
const timer =
|
|
832
|
+
timeoutMs !== undefined && timeoutMs > 0
|
|
833
|
+
? setTimeout(() => {
|
|
834
|
+
didTimeout = true;
|
|
835
|
+
controller.abort();
|
|
836
|
+
}, timeoutMs)
|
|
837
|
+
: undefined;
|
|
838
|
+
return {
|
|
839
|
+
signal: controller.signal,
|
|
840
|
+
timedOut: () => didTimeout,
|
|
841
|
+
dispose: () => {
|
|
842
|
+
if (timer) clearTimeout(timer);
|
|
843
|
+
parent?.removeEventListener("abort", onAbort);
|
|
844
|
+
},
|
|
845
|
+
abort: () => controller.abort(),
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
async function synthesizeChunkWithTimeout(
|
|
850
|
+
ssml: string,
|
|
851
|
+
config: TtsConfig,
|
|
852
|
+
retryOptions: RetryOptions | undefined,
|
|
853
|
+
timeoutMs: number | undefined,
|
|
854
|
+
onRetry: (retryAttempt: number, nextRetryDelayMs: number) => void,
|
|
855
|
+
deadlineAtMs?: number,
|
|
856
|
+
): Promise<SsmlSynthesisResult> {
|
|
857
|
+
const scope = createAbortScope(config.signal, timeoutMs);
|
|
858
|
+
try {
|
|
859
|
+
return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry, deadlineAtMs);
|
|
860
|
+
} catch (error) {
|
|
861
|
+
if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
|
|
862
|
+
throw error;
|
|
863
|
+
} finally {
|
|
864
|
+
scope.dispose();
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
659
868
|
/** Synthesizes chunks with bounded concurrency, retries transient failures, and merges in chunk order. */
|
|
660
869
|
export async function synthesizeSsmlChunks(
|
|
661
870
|
chunks: readonly (SsmlSynthesisChunk | string)[],
|
|
662
871
|
config: TtsConfig,
|
|
663
872
|
): Promise<SsmlSynthesisResult> {
|
|
664
|
-
const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
|
|
665
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);
|
|
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
|
+
}));
|
|
886
|
+
for (const [index, cached] of cachedChunks) {
|
|
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
|
+
}
|
|
895
|
+
}
|
|
896
|
+
const requestedIndices = config.resumeChunkIndices
|
|
897
|
+
? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks))
|
|
898
|
+
: undefined;
|
|
899
|
+
const shouldSynthesize = (index: number): boolean =>
|
|
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;
|
|
907
|
+
const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
|
|
666
908
|
const report = (event: SynthesisProgressEvent): void => config.onProgress?.(event);
|
|
667
|
-
for (const [index,
|
|
668
|
-
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
909
|
+
for (const [index, input] of inputs.entries()) {
|
|
669
910
|
report({
|
|
670
911
|
currentChunk: index,
|
|
671
912
|
totalChunks,
|
|
@@ -676,15 +917,18 @@ export async function synthesizeSsmlChunks(
|
|
|
676
917
|
durationMs: 0,
|
|
677
918
|
});
|
|
678
919
|
}
|
|
679
|
-
let completed =
|
|
920
|
+
let completed = [...results].filter((result) => result !== undefined).length;
|
|
680
921
|
let nextIndex = 0;
|
|
681
922
|
const concurrency = resolveConcurrency(config.concurrency, chunks.length);
|
|
923
|
+
let firstError: unknown;
|
|
924
|
+
const failedIndices = new Set<number>();
|
|
682
925
|
const worker = async (): Promise<void> => {
|
|
683
926
|
while (true) {
|
|
684
927
|
const index = nextIndex++;
|
|
685
928
|
if (index >= chunks.length) return;
|
|
686
|
-
|
|
687
|
-
|
|
929
|
+
if (!shouldSynthesize(index)) continue;
|
|
930
|
+
if (firstError && config.cancelOnFailure !== false) return;
|
|
931
|
+
const input = inputs[index];
|
|
688
932
|
report({
|
|
689
933
|
currentChunk: completed,
|
|
690
934
|
totalChunks,
|
|
@@ -696,10 +940,11 @@ export async function synthesizeSsmlChunks(
|
|
|
696
940
|
});
|
|
697
941
|
const startedAt = Date.now();
|
|
698
942
|
try {
|
|
699
|
-
const result = await
|
|
943
|
+
const result = await synthesizeChunkWithTimeout(
|
|
700
944
|
input.ssml,
|
|
701
945
|
{
|
|
702
946
|
...config,
|
|
947
|
+
signal: scope.signal,
|
|
703
948
|
...(input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {}),
|
|
704
949
|
...((input.sourceNodePath ?? config.sourceNodePath)
|
|
705
950
|
? { sourceNodePath: [...(input.sourceNodePath ?? config.sourceNodePath ?? [])] }
|
|
@@ -710,6 +955,7 @@ export async function synthesizeSsmlChunks(
|
|
|
710
955
|
onProgress: undefined,
|
|
711
956
|
},
|
|
712
957
|
config.retryOptions,
|
|
958
|
+
config.timeouts?.chunkWithRetriesMs ?? config.timeouts?.perChunkMs ?? config.timeoutMs,
|
|
713
959
|
(retryAttempt, nextRetryDelayMs) =>
|
|
714
960
|
report({
|
|
715
961
|
currentChunk: completed,
|
|
@@ -723,8 +969,10 @@ export async function synthesizeSsmlChunks(
|
|
|
723
969
|
nextRetryDelayMs,
|
|
724
970
|
isRetrying: true,
|
|
725
971
|
}),
|
|
972
|
+
jobDeadlineAt,
|
|
726
973
|
);
|
|
727
974
|
results[index] = result;
|
|
975
|
+
chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result };
|
|
728
976
|
completed += 1;
|
|
729
977
|
report({
|
|
730
978
|
currentChunk: completed,
|
|
@@ -736,6 +984,18 @@ export async function synthesizeSsmlChunks(
|
|
|
736
984
|
durationMs: Date.now() - startedAt,
|
|
737
985
|
});
|
|
738
986
|
} catch (error) {
|
|
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
|
+
};
|
|
739
999
|
report({
|
|
740
1000
|
currentChunk: completed,
|
|
741
1001
|
totalChunks,
|
|
@@ -746,16 +1006,53 @@ export async function synthesizeSsmlChunks(
|
|
|
746
1006
|
durationMs: Date.now() - startedAt,
|
|
747
1007
|
error,
|
|
748
1008
|
});
|
|
749
|
-
|
|
1009
|
+
if (config.cancelOnFailure !== false) scope.abort();
|
|
1010
|
+
return;
|
|
750
1011
|
}
|
|
751
1012
|
}
|
|
752
1013
|
};
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
1014
|
+
try {
|
|
1015
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
|
|
1016
|
+
if (firstError) throw firstError;
|
|
1017
|
+
const orderedResults = results.filter((result): result is SsmlSynthesisResult => result !== undefined);
|
|
1018
|
+
return await mergeSynthesisResults(orderedResults, {
|
|
1019
|
+
format: (config.outputFormat ?? DEFAULT_OUTPUT_FORMAT) as AzureTtsOutputFormat,
|
|
1020
|
+
signal: scope.signal,
|
|
1021
|
+
customMerger: config.customMerger,
|
|
1022
|
+
outputMimeType: config.outputMimeType,
|
|
1023
|
+
postMergeValidator: config.postMergeValidator,
|
|
1024
|
+
});
|
|
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
|
+
);
|
|
1036
|
+
const partial = {
|
|
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
|
+
),
|
|
1044
|
+
failedChunkIndices: [...failedIndices],
|
|
1045
|
+
cancelledChunkIndices: chunkStates
|
|
1046
|
+
.filter((state) => state.status === "cancelled")
|
|
1047
|
+
.map((state) => state.chunkIndex),
|
|
1048
|
+
chunkStates,
|
|
1049
|
+
totalChunks,
|
|
1050
|
+
};
|
|
1051
|
+
if (error && typeof error === "object") (error as { partialResult?: unknown }).partialResult = partial;
|
|
1052
|
+
throw error;
|
|
1053
|
+
} finally {
|
|
1054
|
+
scope.dispose();
|
|
1055
|
+
}
|
|
759
1056
|
}
|
|
760
1057
|
|
|
761
1058
|
/** Concatenates audio buffers and shifts all synchronization events by prior chunk durations. */
|
|
@@ -845,6 +1142,10 @@ export function mergeSynthesisResults(
|
|
|
845
1142
|
results: readonly SsmlSynthesisResult[],
|
|
846
1143
|
options: AsyncMergeSynthesisOptions,
|
|
847
1144
|
): Promise<MergedSynthesisResult>;
|
|
1145
|
+
export function mergeSynthesisResults(
|
|
1146
|
+
results: readonly SsmlSynthesisResult[],
|
|
1147
|
+
options: MergeSynthesisOptions,
|
|
1148
|
+
): MergedSynthesisResult | Promise<MergedSynthesisResult>;
|
|
848
1149
|
export function mergeSynthesisResults(
|
|
849
1150
|
results: readonly SsmlSynthesisResult[],
|
|
850
1151
|
options: MergeAudioOptions,
|
|
@@ -874,19 +1175,26 @@ export function mergeSynthesisResults(
|
|
|
874
1175
|
)
|
|
875
1176
|
.then((merged) => {
|
|
876
1177
|
if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
|
|
877
|
-
if (
|
|
878
|
-
!(merged instanceof ArrayBuffer) ||
|
|
879
|
-
(buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
|
|
880
|
-
)
|
|
881
|
-
throw new MergeError("The custom audio merger returned an invalid audio buffer.");
|
|
882
1178
|
if (signal.aborted) throw new SynthesisCancelledError();
|
|
883
|
-
|
|
884
|
-
results,
|
|
1179
|
+
const mergedSpec = validateMergedAudioBuffer(
|
|
885
1180
|
merged,
|
|
886
1181
|
format,
|
|
887
|
-
|
|
888
|
-
|
|
1182
|
+
buffers,
|
|
1183
|
+
inputSpecs,
|
|
1184
|
+
resolvedOptions.outputMimeType ?? resolveMimeType(format),
|
|
889
1185
|
);
|
|
1186
|
+
const result = createMergedResult(results, merged, format, mergedSpec, resolvedOptions.outputMimeType);
|
|
1187
|
+
return Promise.resolve(
|
|
1188
|
+
resolvedOptions.postMergeValidator?.(result, {
|
|
1189
|
+
format,
|
|
1190
|
+
outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
|
|
1191
|
+
inputSpecs,
|
|
1192
|
+
signal,
|
|
1193
|
+
}),
|
|
1194
|
+
).then((valid) => {
|
|
1195
|
+
if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
|
|
1196
|
+
return result;
|
|
1197
|
+
});
|
|
890
1198
|
})
|
|
891
1199
|
.catch((error: unknown) => {
|
|
892
1200
|
if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
|
|
@@ -895,13 +1203,28 @@ export function mergeSynthesisResults(
|
|
|
895
1203
|
});
|
|
896
1204
|
}
|
|
897
1205
|
try {
|
|
898
|
-
|
|
1206
|
+
const result = createMergedResult(
|
|
899
1207
|
results,
|
|
900
1208
|
mergeAudioBuffers(buffers, { format }),
|
|
901
1209
|
format,
|
|
902
1210
|
inputSpecs[0],
|
|
903
1211
|
resolvedOptions.outputMimeType,
|
|
904
1212
|
);
|
|
1213
|
+
if (resolvedOptions.postMergeValidator) {
|
|
1214
|
+
const validation = resolvedOptions.postMergeValidator(result, {
|
|
1215
|
+
format,
|
|
1216
|
+
outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
|
|
1217
|
+
inputSpecs,
|
|
1218
|
+
signal,
|
|
1219
|
+
});
|
|
1220
|
+
if (validation instanceof Promise)
|
|
1221
|
+
return validation.then((valid) => {
|
|
1222
|
+
if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
|
|
1223
|
+
return result;
|
|
1224
|
+
});
|
|
1225
|
+
if (validation === false) throw new MergeError("The post-merge validator rejected the merged audio.");
|
|
1226
|
+
}
|
|
1227
|
+
return result;
|
|
905
1228
|
} catch (error) {
|
|
906
1229
|
if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
|
|
907
1230
|
throw error;
|