@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/dist/index.js
CHANGED
|
@@ -49,6 +49,7 @@ __export(index_exports, {
|
|
|
49
49
|
SynthesisTimeoutError: () => SynthesisTimeoutError,
|
|
50
50
|
UnsupportedMergeFormatError: () => UnsupportedMergeFormatError,
|
|
51
51
|
canMergeAudioFormat: () => canMergeAudioFormat,
|
|
52
|
+
computeChunkFingerprint: () => computeChunkFingerprint,
|
|
52
53
|
fetchAzureVoiceCatalog: () => fetchAzureVoiceCatalog,
|
|
53
54
|
getRetryAfterDelayMs: () => getRetryAfterDelayMs,
|
|
54
55
|
inspectAudioSpecification: () => inspectAudioSpecification,
|
|
@@ -215,6 +216,9 @@ var OUTPUT_FORMATS = {
|
|
|
215
216
|
function resolveMimeType(outputFormat) {
|
|
216
217
|
if (/(?:wav|wave|riff)/i.test(outputFormat)) return "audio/wav";
|
|
217
218
|
if (/(?:mp3|mpeg)/i.test(outputFormat)) return "audio/mpeg";
|
|
219
|
+
if (/mulaw|mu-law/i.test(outputFormat)) return "audio/basic";
|
|
220
|
+
if (/alaw|a-law/i.test(outputFormat)) return "audio/alaw";
|
|
221
|
+
if (/siren/i.test(outputFormat)) return "audio/siren";
|
|
218
222
|
if (/ogg/i.test(outputFormat)) return "audio/ogg";
|
|
219
223
|
if (/webm/i.test(outputFormat)) return "audio/webm";
|
|
220
224
|
if (/raw/i.test(outputFormat)) return "audio/L16";
|
|
@@ -243,6 +247,27 @@ function createSpeechConfig(config) {
|
|
|
243
247
|
}
|
|
244
248
|
|
|
245
249
|
// src/synthesis.ts
|
|
250
|
+
function computeChunkFingerprint(ssml, outputFormat = DEFAULT_OUTPUT_FORMAT) {
|
|
251
|
+
const readAttribute = (name) => {
|
|
252
|
+
const pattern = new RegExp(`(?:${name})\\s*=\\s*[\\"']([^\\"']*)`, "gi");
|
|
253
|
+
return [...ssml.matchAll(pattern)].map((match) => match[1] ?? "").join("|");
|
|
254
|
+
};
|
|
255
|
+
const payload = JSON.stringify({
|
|
256
|
+
ssml,
|
|
257
|
+
outputFormat,
|
|
258
|
+
voice: readAttribute("(?:name|voice)"),
|
|
259
|
+
language: readAttribute("(?:xml:lang|lang)"),
|
|
260
|
+
rate: readAttribute("rate"),
|
|
261
|
+
pitch: readAttribute("pitch")
|
|
262
|
+
});
|
|
263
|
+
let hash = 0xcbf29ce484222325n;
|
|
264
|
+
const mask = 0xffffffffffffffffn;
|
|
265
|
+
for (let index = 0; index < payload.length; index += 1) {
|
|
266
|
+
hash ^= BigInt(payload.charCodeAt(index));
|
|
267
|
+
hash = hash * 0x100000001b3n & mask;
|
|
268
|
+
}
|
|
269
|
+
return `fnv1a64-${hash.toString(16).padStart(16, "0")}`;
|
|
270
|
+
}
|
|
246
271
|
function ascii(bytes, offset, value) {
|
|
247
272
|
return [...value].every((character, index) => bytes[offset + index] === character.charCodeAt(0));
|
|
248
273
|
}
|
|
@@ -282,9 +307,11 @@ function parseWav(buffer) {
|
|
|
282
307
|
}
|
|
283
308
|
return { chunks, data, format };
|
|
284
309
|
}
|
|
285
|
-
function
|
|
286
|
-
const match =
|
|
287
|
-
|
|
310
|
+
function formatSampleRate(format) {
|
|
311
|
+
const match = /(?:^|-)(\d+)(khz|hz)(?:-|$)/i.exec(format);
|
|
312
|
+
if (!match?.[1] || !match[2]) return 0;
|
|
313
|
+
const value = Number(match[1]);
|
|
314
|
+
return match[2].toLowerCase() === "khz" ? value * 1e3 : value;
|
|
288
315
|
}
|
|
289
316
|
function formatChannels(format, fallback) {
|
|
290
317
|
if (/stereo|2ch|dual/i.test(format)) return 2;
|
|
@@ -292,11 +319,11 @@ function formatChannels(format, fallback) {
|
|
|
292
319
|
return fallback;
|
|
293
320
|
}
|
|
294
321
|
function formatAudioSpecification(format) {
|
|
295
|
-
const sampleRate =
|
|
322
|
+
const sampleRate = formatSampleRate(format);
|
|
296
323
|
const channels = formatChannels(format, 0);
|
|
297
324
|
const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
|
|
298
325
|
const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1e3 : void 0;
|
|
299
|
-
const codec = /mp3|mpeg/i.test(format) ? "mp3" : /opus/i.test(format) ? "opus" : /silk/i.test(format) ? "silk" : /
|
|
326
|
+
const codec = /mp3|mpeg/i.test(format) ? "mp3" : /opus/i.test(format) ? "opus" : /silk/i.test(format) ? "silk" : /mulaw|mu-law/i.test(format) ? "mulaw" : /alaw|a-law/i.test(format) ? "alaw" : /siren/i.test(format) ? "siren" : /pcm/i.test(format) ? "pcm" : "unknown";
|
|
300
327
|
const bitDepthMatch = /(\d+)bit/i.exec(format);
|
|
301
328
|
const container = /(?:wav|wave|riff)/i.test(format) ? "riff-wave" : /mp3|mpeg/i.test(format) ? "mp3-raw" : /ogg/i.test(format) ? "ogg" : /webm/i.test(format) ? "webm" : /raw/i.test(format) ? "raw" : void 0;
|
|
302
329
|
return {
|
|
@@ -309,7 +336,7 @@ function formatAudioSpecification(format) {
|
|
|
309
336
|
...bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {},
|
|
310
337
|
...container ? { container } : {},
|
|
311
338
|
isVbr: /vbr/i.test(format),
|
|
312
|
-
isCompressed: codec
|
|
339
|
+
isCompressed: codec !== "pcm" && codec !== "unknown"
|
|
313
340
|
};
|
|
314
341
|
}
|
|
315
342
|
function parseMp3Specification(buffer, format) {
|
|
@@ -360,27 +387,43 @@ function inspectAudioSpecification(buffer, format) {
|
|
|
360
387
|
const channels = view.getUint16(2, true);
|
|
361
388
|
const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
|
|
362
389
|
const formatCode = view.getUint16(0, true);
|
|
390
|
+
const namedCodec = formatAudioSpecification(format).codec;
|
|
391
|
+
const codec = formatCode === 1 ? "pcm" : formatCode === 6 ? "alaw" : formatCode === 7 ? "mulaw" : namedCodec === "siren" ? "siren" : "unknown";
|
|
363
392
|
return {
|
|
364
393
|
format,
|
|
365
394
|
mimeType: "audio/wav",
|
|
366
|
-
codec
|
|
395
|
+
codec,
|
|
367
396
|
sampleRate,
|
|
368
397
|
channels,
|
|
369
398
|
...sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {},
|
|
370
399
|
bitDepth: bitsPerSample,
|
|
371
400
|
container: "riff-wave",
|
|
372
401
|
isVbr: false,
|
|
373
|
-
isCompressed:
|
|
402
|
+
isCompressed: codec !== "pcm" && codec !== "unknown"
|
|
374
403
|
};
|
|
375
404
|
}
|
|
376
405
|
if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
|
|
377
|
-
|
|
406
|
+
const specification = formatAudioSpecification(format);
|
|
407
|
+
if (specification.container === "raw") validateRawAudioBuffer(buffer, specification);
|
|
408
|
+
return specification;
|
|
409
|
+
}
|
|
410
|
+
function validateRawAudioBuffer(buffer, specification) {
|
|
411
|
+
if (specification.sampleRate <= 0 || specification.channels <= 0 || specification.bitDepth === void 0) {
|
|
412
|
+
throw new Error(`RAW audio format "${specification.format}" does not define a complete audio specification.`);
|
|
413
|
+
}
|
|
414
|
+
if (specification.codec === "siren" || specification.codec === "silk") return;
|
|
415
|
+
const bytesPerFrame = specification.channels * Math.ceil(specification.bitDepth / 8);
|
|
416
|
+
if (bytesPerFrame <= 0 || buffer.byteLength % bytesPerFrame !== 0) {
|
|
417
|
+
throw new Error(
|
|
418
|
+
`RAW audio buffer size ${buffer.byteLength} is not aligned to ${bytesPerFrame}-byte audio frames for "${specification.format}".`
|
|
419
|
+
);
|
|
420
|
+
}
|
|
378
421
|
}
|
|
379
422
|
function validateAudioSpecifications(specs) {
|
|
380
423
|
const first = specs[0];
|
|
381
424
|
if (!first) return;
|
|
382
425
|
const mismatch = specs.find(
|
|
383
|
-
(spec) => spec.sampleRate !== first.sampleRate || spec.channels !== first.channels || first.bitrate !== void 0 && spec.bitrate !== void 0 && spec.bitrate !== first.bitrate || first.bitDepth !== void 0 && spec.bitDepth !== void 0 && spec.bitDepth !== first.bitDepth || first.container !== void 0 && spec.container !== void 0 && spec.container !== first.container || first.isVbr !== void 0 && spec.isVbr !== void 0 && spec.isVbr !== first.isVbr
|
|
426
|
+
(spec) => spec.sampleRate !== first.sampleRate || spec.channels !== first.channels || spec.codec !== first.codec || first.bitrate !== void 0 && spec.bitrate !== void 0 && spec.bitrate !== first.bitrate || first.bitDepth !== void 0 && spec.bitDepth !== void 0 && spec.bitDepth !== first.bitDepth || first.container !== void 0 && spec.container !== void 0 && spec.container !== first.container || first.isVbr !== void 0 && spec.isVbr !== void 0 && spec.isVbr !== first.isVbr
|
|
384
427
|
);
|
|
385
428
|
if (mismatch)
|
|
386
429
|
throw new AudioFormatMismatchError(
|
|
@@ -463,6 +506,30 @@ function isWavFormat(format) {
|
|
|
463
506
|
function isRawFormat(format) {
|
|
464
507
|
return /^raw(?:-|$)/i.test(format);
|
|
465
508
|
}
|
|
509
|
+
function validateMergedAudioBuffer(merged, format, buffers, inputSpecs, outputMimeType) {
|
|
510
|
+
if (!(merged instanceof ArrayBuffer) || merged.byteLength === 0) {
|
|
511
|
+
throw new MergeError("The custom audio merger returned an empty or invalid audio buffer.");
|
|
512
|
+
}
|
|
513
|
+
const specification = inspectAudioSpecification(merged, format);
|
|
514
|
+
if (!outputMimeType.trim()) throw new MergeError("The custom audio merger output MIME type cannot be empty.");
|
|
515
|
+
const firstInput = inputSpecs[0];
|
|
516
|
+
if (firstInput && (specification.sampleRate !== firstInput.sampleRate || specification.channels !== firstInput.channels || specification.codec !== firstInput.codec || firstInput.bitDepth !== void 0 && specification.bitDepth !== firstInput.bitDepth)) {
|
|
517
|
+
throw new AudioFormatMismatchError("The custom audio merger returned an incompatible audio stream.", [
|
|
518
|
+
...inputSpecs,
|
|
519
|
+
specification
|
|
520
|
+
]);
|
|
521
|
+
}
|
|
522
|
+
if (isRawFormat(format)) {
|
|
523
|
+
const expectedSize = buffers.reduce((total, input) => total + input.byteLength, 0);
|
|
524
|
+
if (merged.byteLength !== expectedSize) {
|
|
525
|
+
throw new MergeError(
|
|
526
|
+
`The custom audio merger returned ${merged.byteLength} bytes; ${expectedSize} were expected.`
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
validateRawAudioBuffer(merged, specification);
|
|
530
|
+
}
|
|
531
|
+
return specification;
|
|
532
|
+
}
|
|
466
533
|
function resolveMergeAudioFormat(format) {
|
|
467
534
|
if (isWavFormat(format)) return "wav";
|
|
468
535
|
if (isMp3Format(format)) return "mp3";
|
|
@@ -515,7 +582,7 @@ function closeSpeechResources(speechConfig, synthesizer) {
|
|
|
515
582
|
}
|
|
516
583
|
}
|
|
517
584
|
var ticksToMilliseconds = (ticks) => Math.max(0, ticks) / 1e4;
|
|
518
|
-
async function
|
|
585
|
+
async function synthesizeSsmlOnce(ssml, config) {
|
|
519
586
|
if (config.signal?.aborted) {
|
|
520
587
|
throw new SynthesisCancelledError();
|
|
521
588
|
}
|
|
@@ -645,6 +712,13 @@ async function synthesizeSsml(ssml, config) {
|
|
|
645
712
|
rejectWithError(err);
|
|
646
713
|
return;
|
|
647
714
|
}
|
|
715
|
+
let audioSpec;
|
|
716
|
+
try {
|
|
717
|
+
audioSpec = inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT);
|
|
718
|
+
} catch (error) {
|
|
719
|
+
rejectWithError(error);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
648
722
|
settled = true;
|
|
649
723
|
cleanup();
|
|
650
724
|
closeResources();
|
|
@@ -680,8 +754,8 @@ async function synthesizeSsml(ssml, config) {
|
|
|
680
754
|
resolve({
|
|
681
755
|
audioData: result.audioData,
|
|
682
756
|
durationMs,
|
|
683
|
-
audioSpec
|
|
684
|
-
mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
|
|
757
|
+
audioSpec,
|
|
758
|
+
mimeType: config.outputMimeType ?? resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
|
|
685
759
|
...config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {},
|
|
686
760
|
...requestId ? { requestId } : {},
|
|
687
761
|
...sourceBoundaries.length > 0 ? { boundaries: sourceBoundaries, wordBoundary: sourceBoundaries, wordBoundaries: sourceBoundaries } : {},
|
|
@@ -694,7 +768,7 @@ async function synthesizeSsml(ssml, config) {
|
|
|
694
768
|
abortHandler = () => rejectWithError(new SynthesisCancelledError());
|
|
695
769
|
config.signal.addEventListener("abort", abortHandler, { once: true });
|
|
696
770
|
}
|
|
697
|
-
const timeoutMs = config.timeouts?.perChunkMs ?? config.timeoutMs;
|
|
771
|
+
const timeoutMs = config.timeouts?.perChunkMs ?? config.timeouts?.totalJobMs ?? config.timeoutMs;
|
|
698
772
|
if (timeoutMs !== void 0 && timeoutMs > 0) {
|
|
699
773
|
timeout = setTimeout(
|
|
700
774
|
() => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
|
|
@@ -747,7 +821,7 @@ async function waitForRetry(delayMs, signal) {
|
|
|
747
821
|
}
|
|
748
822
|
});
|
|
749
823
|
}
|
|
750
|
-
async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
|
|
824
|
+
async function synthesizeWithRetry(ssml, config, retryOptions, onRetry, deadlineAtMs) {
|
|
751
825
|
const options = retryOptions ? {
|
|
752
826
|
maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
|
|
753
827
|
initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
|
|
@@ -758,17 +832,29 @@ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
|
|
|
758
832
|
while (true) {
|
|
759
833
|
if (config.signal?.aborted) throw new SynthesisCancelledError();
|
|
760
834
|
try {
|
|
761
|
-
return await
|
|
835
|
+
return await synthesizeSsmlOnce(ssml, config);
|
|
762
836
|
} catch (error) {
|
|
763
837
|
if (!options || attempt >= options.maxRetries || !(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error)))
|
|
764
838
|
throw error;
|
|
765
839
|
attempt += 1;
|
|
766
840
|
const delayMs = retryDelay(options, attempt, error);
|
|
841
|
+
const remainingMs = deadlineAtMs === void 0 ? void 0 : Math.max(0, deadlineAtMs - Date.now());
|
|
842
|
+
if (getRetryAfterDelayMs(error) !== void 0 && (delayMs > options.maxDelayMs || remainingMs !== void 0 && delayMs > remainingMs)) {
|
|
843
|
+
throw new SynthesisTimeoutError(
|
|
844
|
+
remainingMs === void 0 ? `Retry-After exceeded maxDelayMs (${options.maxDelayMs} ms).` : `Retry-After exceeded the remaining total job timeout (${remainingMs} ms).`
|
|
845
|
+
);
|
|
846
|
+
}
|
|
767
847
|
onRetry(attempt, delayMs);
|
|
768
|
-
await waitForRetry(delayMs, config.signal);
|
|
848
|
+
await waitForRetry(Math.min(delayMs, remainingMs ?? delayMs), config.signal);
|
|
769
849
|
}
|
|
770
850
|
}
|
|
771
851
|
}
|
|
852
|
+
async function synthesizeSsml(ssml, config) {
|
|
853
|
+
const totalJobMs = config.timeouts?.totalJobMs;
|
|
854
|
+
const deadlineAtMs = totalJobMs !== void 0 && totalJobMs > 0 ? Date.now() + totalJobMs : void 0;
|
|
855
|
+
if (!config.retryOptions) return synthesizeSsmlOnce(ssml, config);
|
|
856
|
+
return synthesizeWithRetry(ssml, config, config.retryOptions, () => void 0, deadlineAtMs);
|
|
857
|
+
}
|
|
772
858
|
function createAbortScope(parent, timeoutMs) {
|
|
773
859
|
const controller = new AbortController();
|
|
774
860
|
let didTimeout = false;
|
|
@@ -789,10 +875,10 @@ function createAbortScope(parent, timeoutMs) {
|
|
|
789
875
|
abort: () => controller.abort()
|
|
790
876
|
};
|
|
791
877
|
}
|
|
792
|
-
async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs, onRetry) {
|
|
878
|
+
async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs, onRetry, deadlineAtMs) {
|
|
793
879
|
const scope = createAbortScope(config.signal, timeoutMs);
|
|
794
880
|
try {
|
|
795
|
-
return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry);
|
|
881
|
+
return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry, deadlineAtMs);
|
|
796
882
|
} catch (error) {
|
|
797
883
|
if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
|
|
798
884
|
throw error;
|
|
@@ -801,18 +887,36 @@ async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs,
|
|
|
801
887
|
}
|
|
802
888
|
}
|
|
803
889
|
async function synthesizeSsmlChunks(chunks, config) {
|
|
804
|
-
const results = new Array(chunks.length);
|
|
805
890
|
const totalChunks = chunks.length;
|
|
891
|
+
const inputs = chunks.map((chunk) => typeof chunk === "string" ? { ssml: chunk } : chunk);
|
|
892
|
+
const fingerprints = inputs.map(
|
|
893
|
+
(chunk) => computeChunkFingerprint(chunk.ssml, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT)
|
|
894
|
+
);
|
|
895
|
+
const results = new Array(totalChunks);
|
|
806
896
|
const cachedChunks = new Map((config.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
|
|
897
|
+
const invalidCachedIndices = /* @__PURE__ */ new Set();
|
|
898
|
+
const chunkStates = inputs.map((_chunk, chunkIndex) => ({
|
|
899
|
+
chunkIndex,
|
|
900
|
+
status: "pending",
|
|
901
|
+
canResume: true
|
|
902
|
+
}));
|
|
807
903
|
for (const [index, cached] of cachedChunks) {
|
|
808
|
-
if (index
|
|
904
|
+
if (index < 0 || index >= totalChunks) continue;
|
|
905
|
+
const isValid = config.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index];
|
|
906
|
+
if (isValid) {
|
|
907
|
+
results[index] = { ...cached };
|
|
908
|
+
chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
|
|
909
|
+
} else {
|
|
910
|
+
invalidCachedIndices.add(index);
|
|
911
|
+
}
|
|
809
912
|
}
|
|
810
913
|
const requestedIndices = config.resumeChunkIndices ? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks)) : void 0;
|
|
811
|
-
const shouldSynthesize = (index) => !cachedChunks.has(index) && (requestedIndices === void 0 || requestedIndices.has(index));
|
|
914
|
+
const shouldSynthesize = (index) => (!cachedChunks.has(index) || invalidCachedIndices.has(index)) && (requestedIndices === void 0 || requestedIndices.has(index) || invalidCachedIndices.has(index));
|
|
915
|
+
const jobStartedAt = Date.now();
|
|
916
|
+
const jobDeadlineAt = config.timeouts?.totalJobMs !== void 0 && config.timeouts.totalJobMs > 0 ? jobStartedAt + config.timeouts.totalJobMs : void 0;
|
|
812
917
|
const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
|
|
813
918
|
const report = (event) => config.onProgress?.(event);
|
|
814
|
-
for (const [index,
|
|
815
|
-
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
919
|
+
for (const [index, input] of inputs.entries()) {
|
|
816
920
|
report({
|
|
817
921
|
currentChunk: index,
|
|
818
922
|
totalChunks,
|
|
@@ -834,8 +938,7 @@ async function synthesizeSsmlChunks(chunks, config) {
|
|
|
834
938
|
if (index >= chunks.length) return;
|
|
835
939
|
if (!shouldSynthesize(index)) continue;
|
|
836
940
|
if (firstError && config.cancelOnFailure !== false) return;
|
|
837
|
-
const
|
|
838
|
-
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
941
|
+
const input = inputs[index];
|
|
839
942
|
report({
|
|
840
943
|
currentChunk: completed,
|
|
841
944
|
totalChunks,
|
|
@@ -872,9 +975,11 @@ async function synthesizeSsmlChunks(chunks, config) {
|
|
|
872
975
|
retryAttempt,
|
|
873
976
|
nextRetryDelayMs,
|
|
874
977
|
isRetrying: true
|
|
875
|
-
})
|
|
978
|
+
}),
|
|
979
|
+
jobDeadlineAt
|
|
876
980
|
);
|
|
877
981
|
results[index] = result;
|
|
982
|
+
chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result };
|
|
878
983
|
completed += 1;
|
|
879
984
|
report({
|
|
880
985
|
currentChunk: completed,
|
|
@@ -886,7 +991,16 @@ async function synthesizeSsmlChunks(chunks, config) {
|
|
|
886
991
|
durationMs: Date.now() - startedAt
|
|
887
992
|
});
|
|
888
993
|
} catch (error) {
|
|
889
|
-
|
|
994
|
+
const wasCancelled = firstError !== void 0 || scope.signal.aborted && !scope.timedOut();
|
|
995
|
+
firstError ?? (firstError = scope.timedOut() ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`) : error);
|
|
996
|
+
if (!wasCancelled) failedIndices.add(index);
|
|
997
|
+
chunkStates[index] = {
|
|
998
|
+
chunkIndex: index,
|
|
999
|
+
status: wasCancelled ? "cancelled" : "failed",
|
|
1000
|
+
isOriginalFailure: !wasCancelled,
|
|
1001
|
+
canResume: true,
|
|
1002
|
+
error
|
|
1003
|
+
};
|
|
890
1004
|
report({
|
|
891
1005
|
currentChunk: completed,
|
|
892
1006
|
totalChunks,
|
|
@@ -897,7 +1011,6 @@ async function synthesizeSsmlChunks(chunks, config) {
|
|
|
897
1011
|
durationMs: Date.now() - startedAt,
|
|
898
1012
|
error
|
|
899
1013
|
});
|
|
900
|
-
firstError ?? (firstError = scope.timedOut() ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`) : error);
|
|
901
1014
|
if (config.cancelOnFailure !== false) scope.abort();
|
|
902
1015
|
return;
|
|
903
1016
|
}
|
|
@@ -915,11 +1028,25 @@ async function synthesizeSsmlChunks(chunks, config) {
|
|
|
915
1028
|
postMergeValidator: config.postMergeValidator
|
|
916
1029
|
});
|
|
917
1030
|
} catch (error) {
|
|
1031
|
+
if (firstError && config.cancelOnFailure !== false) {
|
|
1032
|
+
for (const [chunkIndex, state] of chunkStates.entries()) {
|
|
1033
|
+
if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
|
|
1034
|
+
chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
const synthesizedChunks = results.flatMap(
|
|
1039
|
+
(result, chunkIndex) => result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : []
|
|
1040
|
+
);
|
|
918
1041
|
const partial = {
|
|
919
|
-
synthesizedChunks
|
|
920
|
-
completedChunks:
|
|
921
|
-
pendingChunkIndices:
|
|
1042
|
+
synthesizedChunks,
|
|
1043
|
+
completedChunks: synthesizedChunks,
|
|
1044
|
+
pendingChunkIndices: chunkStates.flatMap(
|
|
1045
|
+
(state) => state.status === "pending" || state.status === "cancelled" || state.status === "failed" ? [state.chunkIndex] : []
|
|
1046
|
+
),
|
|
922
1047
|
failedChunkIndices: [...failedIndices],
|
|
1048
|
+
cancelledChunkIndices: chunkStates.filter((state) => state.status === "cancelled").map((state) => state.chunkIndex),
|
|
1049
|
+
chunkStates,
|
|
923
1050
|
totalChunks
|
|
924
1051
|
};
|
|
925
1052
|
if (error && typeof error === "object") error.partialResult = partial;
|
|
@@ -1017,16 +1144,15 @@ function mergeSynthesisResults(results, options) {
|
|
|
1017
1144
|
})
|
|
1018
1145
|
).then((merged) => {
|
|
1019
1146
|
if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
|
|
1020
|
-
if (!(merged instanceof ArrayBuffer) || buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
|
|
1021
|
-
throw new MergeError("The custom audio merger returned an invalid audio buffer.");
|
|
1022
1147
|
if (signal.aborted) throw new SynthesisCancelledError();
|
|
1023
|
-
const
|
|
1024
|
-
results,
|
|
1148
|
+
const mergedSpec = validateMergedAudioBuffer(
|
|
1025
1149
|
merged,
|
|
1026
1150
|
format,
|
|
1027
|
-
|
|
1028
|
-
|
|
1151
|
+
buffers,
|
|
1152
|
+
inputSpecs,
|
|
1153
|
+
resolvedOptions.outputMimeType ?? resolveMimeType(format)
|
|
1029
1154
|
);
|
|
1155
|
+
const result = createMergedResult(results, merged, format, mergedSpec, resolvedOptions.outputMimeType);
|
|
1030
1156
|
return Promise.resolve(
|
|
1031
1157
|
resolvedOptions.postMergeValidator?.(result, {
|
|
1032
1158
|
format,
|
|
@@ -1156,7 +1282,7 @@ function resolveConcurrency2(value, total) {
|
|
|
1156
1282
|
if (value === Infinity) return Math.max(1, total);
|
|
1157
1283
|
return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
|
|
1158
1284
|
}
|
|
1159
|
-
async function retryableSynthesis(synthesize, options, signal, onRetry) {
|
|
1285
|
+
async function retryableSynthesis(synthesize, options, signal, onRetry, deadlineAtMs) {
|
|
1160
1286
|
const retry = options ? {
|
|
1161
1287
|
maxRetries: Math.max(0, Math.floor(options.maxRetries)),
|
|
1162
1288
|
initialDelayMs: options.initialDelayMs,
|
|
@@ -1173,6 +1299,11 @@ async function retryableSynthesis(synthesize, options, signal, onRetry) {
|
|
|
1173
1299
|
throw error;
|
|
1174
1300
|
attempt += 1;
|
|
1175
1301
|
const delayMs = retryDelayForError(retry, attempt, error);
|
|
1302
|
+
const retryAfterMs = getRetryAfterDelayMs(error);
|
|
1303
|
+
const remainingMs = deadlineAtMs === void 0 ? void 0 : Math.max(0, deadlineAtMs - Date.now());
|
|
1304
|
+
if (retryAfterMs !== void 0 && (retryAfterMs > retry.maxDelayMs || remainingMs !== void 0 && retryAfterMs > remainingMs)) {
|
|
1305
|
+
throw new Error("Speech synthesis timed out because Retry-After exceeded the available retry budget.");
|
|
1306
|
+
}
|
|
1176
1307
|
onRetry(attempt, delayMs);
|
|
1177
1308
|
if (delayMs > 0)
|
|
1178
1309
|
await new Promise((resolve, reject) => {
|
|
@@ -1220,20 +1351,24 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
|
|
|
1220
1351
|
diagnostics: errors
|
|
1221
1352
|
});
|
|
1222
1353
|
}
|
|
1354
|
+
const jobScope = options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts.totalJobMs) : void 0;
|
|
1223
1355
|
try {
|
|
1224
1356
|
return {
|
|
1225
1357
|
ok: true,
|
|
1226
1358
|
success: true,
|
|
1227
1359
|
status: "success",
|
|
1228
1360
|
value: await client.synthesizeSsml(ssml, {
|
|
1229
|
-
signal: options.signal,
|
|
1361
|
+
signal: jobScope?.signal ?? options.signal,
|
|
1230
1362
|
timeoutMs: options.timeouts?.perChunkMs,
|
|
1231
1363
|
timeouts: options.timeouts
|
|
1232
1364
|
})
|
|
1233
1365
|
};
|
|
1234
1366
|
} catch (error) {
|
|
1367
|
+
if (jobScope?.timedOut()) return failure(toSynthesisError(new Error("Speech synthesis timed out.")));
|
|
1235
1368
|
const synthesisError = toSynthesisError(error);
|
|
1236
1369
|
return failure(synthesisError);
|
|
1370
|
+
} finally {
|
|
1371
|
+
jobScope?.dispose();
|
|
1237
1372
|
}
|
|
1238
1373
|
}
|
|
1239
1374
|
async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
|
|
@@ -1305,17 +1440,32 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
|
|
|
1305
1440
|
resumeChunkIndices: options.resumeChunkIndices,
|
|
1306
1441
|
customMerger: options.customMerger,
|
|
1307
1442
|
outputMimeType: options.outputMimeType,
|
|
1308
|
-
postMergeValidator: options.postMergeValidator
|
|
1443
|
+
postMergeValidator: options.postMergeValidator,
|
|
1444
|
+
resumeValidation: options.resumeValidation
|
|
1309
1445
|
});
|
|
1310
1446
|
return { ok: true, success: true, status: "success", value };
|
|
1311
1447
|
}
|
|
1448
|
+
const inputs = chunks.map((chunk) => typeof chunk === "string" ? { ssml: chunk } : chunk);
|
|
1449
|
+
const fingerprints = inputs.map((chunk) => computeChunkFingerprint(chunk.ssml, options.outputFormat));
|
|
1312
1450
|
const results = new Array(chunks.length);
|
|
1451
|
+
const chunkStates = inputs.map((_chunk, chunkIndex) => ({
|
|
1452
|
+
chunkIndex,
|
|
1453
|
+
status: "pending",
|
|
1454
|
+
canResume: true
|
|
1455
|
+
}));
|
|
1313
1456
|
const cachedChunks = new Map((options.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
|
|
1457
|
+
const invalidCachedIndices = /* @__PURE__ */ new Set();
|
|
1314
1458
|
for (const [index, cached] of cachedChunks) {
|
|
1315
|
-
if (index
|
|
1459
|
+
if (index < 0 || index >= chunks.length) continue;
|
|
1460
|
+
if (options.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index]) {
|
|
1461
|
+
results[index] = cached;
|
|
1462
|
+
chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: cached };
|
|
1463
|
+
} else invalidCachedIndices.add(index);
|
|
1316
1464
|
}
|
|
1317
1465
|
const requestedIndices = options.resumeChunkIndices ? new Set(options.resumeChunkIndices.filter((index) => index >= 0 && index < chunks.length)) : void 0;
|
|
1318
|
-
const shouldSynthesize = (index) => !cachedChunks.has(index) && (requestedIndices === void 0 || requestedIndices.has(index));
|
|
1466
|
+
const shouldSynthesize = (index) => (!cachedChunks.has(index) || invalidCachedIndices.has(index)) && (requestedIndices === void 0 || requestedIndices.has(index) || invalidCachedIndices.has(index));
|
|
1467
|
+
const jobStartedAt = Date.now();
|
|
1468
|
+
const jobDeadlineAt = options.timeouts?.totalJobMs !== void 0 && options.timeouts.totalJobMs > 0 ? jobStartedAt + options.timeouts.totalJobMs : void 0;
|
|
1319
1469
|
const jobScope = chunks.length > 1 || options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs) : void 0;
|
|
1320
1470
|
fallbackJobScope = jobScope;
|
|
1321
1471
|
const failedIndices = /* @__PURE__ */ new Set();
|
|
@@ -1328,7 +1478,10 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
|
|
|
1328
1478
|
const index = nextIndex++;
|
|
1329
1479
|
if (index >= chunks.length) return;
|
|
1330
1480
|
if (!shouldSynthesize(index)) continue;
|
|
1331
|
-
if (
|
|
1481
|
+
if (firstError && options.cancelOnFailure !== false) {
|
|
1482
|
+
chunkStates[index] = { chunkIndex: index, status: "cancelled", isOriginalFailure: false, canResume: true };
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1332
1485
|
const chunk = chunks[index];
|
|
1333
1486
|
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
1334
1487
|
const sourceNodePath = input.sourceNodePath;
|
|
@@ -1361,7 +1514,8 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
|
|
|
1361
1514
|
retryAttempt,
|
|
1362
1515
|
nextRetryDelayMs,
|
|
1363
1516
|
isRetrying: true
|
|
1364
|
-
})
|
|
1517
|
+
}),
|
|
1518
|
+
jobDeadlineAt
|
|
1365
1519
|
);
|
|
1366
1520
|
} catch (error) {
|
|
1367
1521
|
if (chunkScope?.timedOut())
|
|
@@ -1413,6 +1567,7 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
|
|
|
1413
1567
|
}))
|
|
1414
1568
|
} : {}
|
|
1415
1569
|
};
|
|
1570
|
+
chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
|
|
1416
1571
|
completed += 1;
|
|
1417
1572
|
options.onProgress?.({
|
|
1418
1573
|
currentChunk: completed,
|
|
@@ -1424,7 +1579,16 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
|
|
|
1424
1579
|
durationMs: Date.now() - startedAt
|
|
1425
1580
|
});
|
|
1426
1581
|
} catch (error) {
|
|
1427
|
-
|
|
1582
|
+
const wasCancelled = firstError !== void 0 || Boolean(jobScope?.signal.aborted && !jobScope?.timedOut());
|
|
1583
|
+
firstError ?? (firstError = error);
|
|
1584
|
+
if (!wasCancelled) failedIndices.add(index);
|
|
1585
|
+
chunkStates[index] = {
|
|
1586
|
+
chunkIndex: index,
|
|
1587
|
+
status: wasCancelled ? "cancelled" : "failed",
|
|
1588
|
+
isOriginalFailure: !wasCancelled,
|
|
1589
|
+
canResume: true,
|
|
1590
|
+
error
|
|
1591
|
+
};
|
|
1428
1592
|
options.onProgress?.({
|
|
1429
1593
|
currentChunk: completed,
|
|
1430
1594
|
totalChunks: chunks.length,
|
|
@@ -1436,19 +1600,32 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
|
|
|
1436
1600
|
error
|
|
1437
1601
|
});
|
|
1438
1602
|
if (options.cancelOnFailure !== false) jobScope?.abort();
|
|
1439
|
-
firstError ?? (firstError = error);
|
|
1440
1603
|
return;
|
|
1441
1604
|
}
|
|
1442
1605
|
}
|
|
1443
1606
|
};
|
|
1444
1607
|
await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
|
|
1608
|
+
if (firstError && options.cancelOnFailure !== false) {
|
|
1609
|
+
for (const [chunkIndex, state] of chunkStates.entries()) {
|
|
1610
|
+
if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
|
|
1611
|
+
chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1445
1615
|
if (failedIndices.size > 0) {
|
|
1446
1616
|
const error = firstError ?? new Error("One or more SSML chunks failed to synthesize.");
|
|
1617
|
+
const synthesizedChunks = results.flatMap(
|
|
1618
|
+
(result, chunkIndex) => result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : []
|
|
1619
|
+
);
|
|
1447
1620
|
error.partialResult = {
|
|
1448
|
-
synthesizedChunks
|
|
1449
|
-
completedChunks:
|
|
1450
|
-
pendingChunkIndices:
|
|
1621
|
+
synthesizedChunks,
|
|
1622
|
+
completedChunks: synthesizedChunks,
|
|
1623
|
+
pendingChunkIndices: chunkStates.flatMap(
|
|
1624
|
+
(state) => state.status === "pending" || state.status === "cancelled" || state.status === "failed" ? [state.chunkIndex] : []
|
|
1625
|
+
),
|
|
1451
1626
|
failedChunkIndices: [...failedIndices],
|
|
1627
|
+
cancelledChunkIndices: chunkStates.filter((state) => state.status === "cancelled").map((state) => state.chunkIndex),
|
|
1628
|
+
chunkStates,
|
|
1452
1629
|
totalChunks: chunks.length
|
|
1453
1630
|
};
|
|
1454
1631
|
throw error;
|
|
@@ -1494,7 +1671,16 @@ var AzureTtsClient = class {
|
|
|
1494
1671
|
const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
|
|
1495
1672
|
const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
|
|
1496
1673
|
__privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
|
|
1497
|
-
const config = {
|
|
1674
|
+
const config = {
|
|
1675
|
+
endpoint,
|
|
1676
|
+
region,
|
|
1677
|
+
subscriptionKey,
|
|
1678
|
+
outputFormat,
|
|
1679
|
+
signal,
|
|
1680
|
+
timeoutMs,
|
|
1681
|
+
timeouts,
|
|
1682
|
+
retryOptions: __privateGet(this, _options).retryOptions
|
|
1683
|
+
};
|
|
1498
1684
|
return synthesizeSpeech(ssml, config);
|
|
1499
1685
|
}
|
|
1500
1686
|
async synthesizeSsml(ssml, options = {}) {
|
|
@@ -1511,7 +1697,13 @@ var AzureTtsClient = class {
|
|
|
1511
1697
|
timeouts: options.timeouts ?? timeouts,
|
|
1512
1698
|
sourceNodePath: options.sourceNodePath,
|
|
1513
1699
|
sourceTextSegments: options.sourceTextSegments,
|
|
1514
|
-
sourceMarkers: options.sourceMarkers
|
|
1700
|
+
sourceMarkers: options.sourceMarkers,
|
|
1701
|
+
retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
|
|
1702
|
+
cancelOnFailure: options.cancelOnFailure ?? __privateGet(this, _options).cancelOnFailure,
|
|
1703
|
+
customMerger: options.customMerger ?? __privateGet(this, _options).customMerger,
|
|
1704
|
+
outputMimeType: options.outputMimeType ?? __privateGet(this, _options).outputMimeType,
|
|
1705
|
+
postMergeValidator: options.postMergeValidator ?? __privateGet(this, _options).postMergeValidator,
|
|
1706
|
+
resumeValidation: options.resumeValidation ?? __privateGet(this, _options).resumeValidation
|
|
1515
1707
|
});
|
|
1516
1708
|
}
|
|
1517
1709
|
async synthesizeChunks(chunks, options = {}) {
|
|
@@ -1529,12 +1721,13 @@ var AzureTtsClient = class {
|
|
|
1529
1721
|
onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
|
|
1530
1722
|
concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
|
|
1531
1723
|
retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
|
|
1532
|
-
cancelOnFailure: options.cancelOnFailure,
|
|
1724
|
+
cancelOnFailure: options.cancelOnFailure ?? __privateGet(this, _options).cancelOnFailure,
|
|
1533
1725
|
resumeChunks: options.resumeChunks,
|
|
1534
1726
|
resumeChunkIndices: options.resumeChunkIndices,
|
|
1535
|
-
customMerger: options.customMerger,
|
|
1536
|
-
outputMimeType: options.outputMimeType,
|
|
1537
|
-
postMergeValidator: options.postMergeValidator
|
|
1727
|
+
customMerger: options.customMerger ?? __privateGet(this, _options).customMerger,
|
|
1728
|
+
outputMimeType: options.outputMimeType ?? __privateGet(this, _options).outputMimeType,
|
|
1729
|
+
postMergeValidator: options.postMergeValidator ?? __privateGet(this, _options).postMergeValidator,
|
|
1730
|
+
resumeValidation: options.resumeValidation ?? __privateGet(this, _options).resumeValidation
|
|
1538
1731
|
});
|
|
1539
1732
|
}
|
|
1540
1733
|
async synthesizeSsmlSafe(ssml, options = {}) {
|
|
@@ -1660,6 +1853,7 @@ async function fetchAzureVoiceCatalog(options) {
|
|
|
1660
1853
|
SynthesisTimeoutError,
|
|
1661
1854
|
UnsupportedMergeFormatError,
|
|
1662
1855
|
canMergeAudioFormat,
|
|
1856
|
+
computeChunkFingerprint,
|
|
1663
1857
|
fetchAzureVoiceCatalog,
|
|
1664
1858
|
getRetryAfterDelayMs,
|
|
1665
1859
|
inspectAudioSpecification,
|