@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.mjs
CHANGED
|
@@ -157,6 +157,9 @@ var OUTPUT_FORMATS = {
|
|
|
157
157
|
function resolveMimeType(outputFormat) {
|
|
158
158
|
if (/(?:wav|wave|riff)/i.test(outputFormat)) return "audio/wav";
|
|
159
159
|
if (/(?:mp3|mpeg)/i.test(outputFormat)) return "audio/mpeg";
|
|
160
|
+
if (/mulaw|mu-law/i.test(outputFormat)) return "audio/basic";
|
|
161
|
+
if (/alaw|a-law/i.test(outputFormat)) return "audio/alaw";
|
|
162
|
+
if (/siren/i.test(outputFormat)) return "audio/siren";
|
|
160
163
|
if (/ogg/i.test(outputFormat)) return "audio/ogg";
|
|
161
164
|
if (/webm/i.test(outputFormat)) return "audio/webm";
|
|
162
165
|
if (/raw/i.test(outputFormat)) return "audio/L16";
|
|
@@ -185,6 +188,27 @@ function createSpeechConfig(config) {
|
|
|
185
188
|
}
|
|
186
189
|
|
|
187
190
|
// src/synthesis.ts
|
|
191
|
+
function computeChunkFingerprint(ssml, outputFormat = DEFAULT_OUTPUT_FORMAT) {
|
|
192
|
+
const readAttribute = (name) => {
|
|
193
|
+
const pattern = new RegExp(`(?:${name})\\s*=\\s*[\\"']([^\\"']*)`, "gi");
|
|
194
|
+
return [...ssml.matchAll(pattern)].map((match) => match[1] ?? "").join("|");
|
|
195
|
+
};
|
|
196
|
+
const payload = JSON.stringify({
|
|
197
|
+
ssml,
|
|
198
|
+
outputFormat,
|
|
199
|
+
voice: readAttribute("(?:name|voice)"),
|
|
200
|
+
language: readAttribute("(?:xml:lang|lang)"),
|
|
201
|
+
rate: readAttribute("rate"),
|
|
202
|
+
pitch: readAttribute("pitch")
|
|
203
|
+
});
|
|
204
|
+
let hash = 0xcbf29ce484222325n;
|
|
205
|
+
const mask = 0xffffffffffffffffn;
|
|
206
|
+
for (let index = 0; index < payload.length; index += 1) {
|
|
207
|
+
hash ^= BigInt(payload.charCodeAt(index));
|
|
208
|
+
hash = hash * 0x100000001b3n & mask;
|
|
209
|
+
}
|
|
210
|
+
return `fnv1a64-${hash.toString(16).padStart(16, "0")}`;
|
|
211
|
+
}
|
|
188
212
|
function ascii(bytes, offset, value) {
|
|
189
213
|
return [...value].every((character, index) => bytes[offset + index] === character.charCodeAt(0));
|
|
190
214
|
}
|
|
@@ -224,9 +248,11 @@ function parseWav(buffer) {
|
|
|
224
248
|
}
|
|
225
249
|
return { chunks, data, format };
|
|
226
250
|
}
|
|
227
|
-
function
|
|
228
|
-
const match =
|
|
229
|
-
|
|
251
|
+
function formatSampleRate(format) {
|
|
252
|
+
const match = /(?:^|-)(\d+)(khz|hz)(?:-|$)/i.exec(format);
|
|
253
|
+
if (!match?.[1] || !match[2]) return 0;
|
|
254
|
+
const value = Number(match[1]);
|
|
255
|
+
return match[2].toLowerCase() === "khz" ? value * 1e3 : value;
|
|
230
256
|
}
|
|
231
257
|
function formatChannels(format, fallback) {
|
|
232
258
|
if (/stereo|2ch|dual/i.test(format)) return 2;
|
|
@@ -234,11 +260,11 @@ function formatChannels(format, fallback) {
|
|
|
234
260
|
return fallback;
|
|
235
261
|
}
|
|
236
262
|
function formatAudioSpecification(format) {
|
|
237
|
-
const sampleRate =
|
|
263
|
+
const sampleRate = formatSampleRate(format);
|
|
238
264
|
const channels = formatChannels(format, 0);
|
|
239
265
|
const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
|
|
240
266
|
const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1e3 : void 0;
|
|
241
|
-
const codec = /mp3|mpeg/i.test(format) ? "mp3" : /opus/i.test(format) ? "opus" : /silk/i.test(format) ? "silk" : /
|
|
267
|
+
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";
|
|
242
268
|
const bitDepthMatch = /(\d+)bit/i.exec(format);
|
|
243
269
|
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;
|
|
244
270
|
return {
|
|
@@ -251,7 +277,7 @@ function formatAudioSpecification(format) {
|
|
|
251
277
|
...bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {},
|
|
252
278
|
...container ? { container } : {},
|
|
253
279
|
isVbr: /vbr/i.test(format),
|
|
254
|
-
isCompressed: codec
|
|
280
|
+
isCompressed: codec !== "pcm" && codec !== "unknown"
|
|
255
281
|
};
|
|
256
282
|
}
|
|
257
283
|
function parseMp3Specification(buffer, format) {
|
|
@@ -302,27 +328,43 @@ function inspectAudioSpecification(buffer, format) {
|
|
|
302
328
|
const channels = view.getUint16(2, true);
|
|
303
329
|
const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
|
|
304
330
|
const formatCode = view.getUint16(0, true);
|
|
331
|
+
const namedCodec = formatAudioSpecification(format).codec;
|
|
332
|
+
const codec = formatCode === 1 ? "pcm" : formatCode === 6 ? "alaw" : formatCode === 7 ? "mulaw" : namedCodec === "siren" ? "siren" : "unknown";
|
|
305
333
|
return {
|
|
306
334
|
format,
|
|
307
335
|
mimeType: "audio/wav",
|
|
308
|
-
codec
|
|
336
|
+
codec,
|
|
309
337
|
sampleRate,
|
|
310
338
|
channels,
|
|
311
339
|
...sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {},
|
|
312
340
|
bitDepth: bitsPerSample,
|
|
313
341
|
container: "riff-wave",
|
|
314
342
|
isVbr: false,
|
|
315
|
-
isCompressed:
|
|
343
|
+
isCompressed: codec !== "pcm" && codec !== "unknown"
|
|
316
344
|
};
|
|
317
345
|
}
|
|
318
346
|
if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
|
|
319
|
-
|
|
347
|
+
const specification = formatAudioSpecification(format);
|
|
348
|
+
if (specification.container === "raw") validateRawAudioBuffer(buffer, specification);
|
|
349
|
+
return specification;
|
|
350
|
+
}
|
|
351
|
+
function validateRawAudioBuffer(buffer, specification) {
|
|
352
|
+
if (specification.sampleRate <= 0 || specification.channels <= 0 || specification.bitDepth === void 0) {
|
|
353
|
+
throw new Error(`RAW audio format "${specification.format}" does not define a complete audio specification.`);
|
|
354
|
+
}
|
|
355
|
+
if (specification.codec === "siren" || specification.codec === "silk") return;
|
|
356
|
+
const bytesPerFrame = specification.channels * Math.ceil(specification.bitDepth / 8);
|
|
357
|
+
if (bytesPerFrame <= 0 || buffer.byteLength % bytesPerFrame !== 0) {
|
|
358
|
+
throw new Error(
|
|
359
|
+
`RAW audio buffer size ${buffer.byteLength} is not aligned to ${bytesPerFrame}-byte audio frames for "${specification.format}".`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
320
362
|
}
|
|
321
363
|
function validateAudioSpecifications(specs) {
|
|
322
364
|
const first = specs[0];
|
|
323
365
|
if (!first) return;
|
|
324
366
|
const mismatch = specs.find(
|
|
325
|
-
(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
|
|
367
|
+
(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
|
|
326
368
|
);
|
|
327
369
|
if (mismatch)
|
|
328
370
|
throw new AudioFormatMismatchError(
|
|
@@ -405,6 +447,30 @@ function isWavFormat(format) {
|
|
|
405
447
|
function isRawFormat(format) {
|
|
406
448
|
return /^raw(?:-|$)/i.test(format);
|
|
407
449
|
}
|
|
450
|
+
function validateMergedAudioBuffer(merged, format, buffers, inputSpecs, outputMimeType) {
|
|
451
|
+
if (!(merged instanceof ArrayBuffer) || merged.byteLength === 0) {
|
|
452
|
+
throw new MergeError("The custom audio merger returned an empty or invalid audio buffer.");
|
|
453
|
+
}
|
|
454
|
+
const specification = inspectAudioSpecification(merged, format);
|
|
455
|
+
if (!outputMimeType.trim()) throw new MergeError("The custom audio merger output MIME type cannot be empty.");
|
|
456
|
+
const firstInput = inputSpecs[0];
|
|
457
|
+
if (firstInput && (specification.sampleRate !== firstInput.sampleRate || specification.channels !== firstInput.channels || specification.codec !== firstInput.codec || firstInput.bitDepth !== void 0 && specification.bitDepth !== firstInput.bitDepth)) {
|
|
458
|
+
throw new AudioFormatMismatchError("The custom audio merger returned an incompatible audio stream.", [
|
|
459
|
+
...inputSpecs,
|
|
460
|
+
specification
|
|
461
|
+
]);
|
|
462
|
+
}
|
|
463
|
+
if (isRawFormat(format)) {
|
|
464
|
+
const expectedSize = buffers.reduce((total, input) => total + input.byteLength, 0);
|
|
465
|
+
if (merged.byteLength !== expectedSize) {
|
|
466
|
+
throw new MergeError(
|
|
467
|
+
`The custom audio merger returned ${merged.byteLength} bytes; ${expectedSize} were expected.`
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
validateRawAudioBuffer(merged, specification);
|
|
471
|
+
}
|
|
472
|
+
return specification;
|
|
473
|
+
}
|
|
408
474
|
function resolveMergeAudioFormat(format) {
|
|
409
475
|
if (isWavFormat(format)) return "wav";
|
|
410
476
|
if (isMp3Format(format)) return "mp3";
|
|
@@ -457,7 +523,7 @@ function closeSpeechResources(speechConfig, synthesizer) {
|
|
|
457
523
|
}
|
|
458
524
|
}
|
|
459
525
|
var ticksToMilliseconds = (ticks) => Math.max(0, ticks) / 1e4;
|
|
460
|
-
async function
|
|
526
|
+
async function synthesizeSsmlOnce(ssml, config) {
|
|
461
527
|
if (config.signal?.aborted) {
|
|
462
528
|
throw new SynthesisCancelledError();
|
|
463
529
|
}
|
|
@@ -587,6 +653,13 @@ async function synthesizeSsml(ssml, config) {
|
|
|
587
653
|
rejectWithError(err);
|
|
588
654
|
return;
|
|
589
655
|
}
|
|
656
|
+
let audioSpec;
|
|
657
|
+
try {
|
|
658
|
+
audioSpec = inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT);
|
|
659
|
+
} catch (error) {
|
|
660
|
+
rejectWithError(error);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
590
663
|
settled = true;
|
|
591
664
|
cleanup();
|
|
592
665
|
closeResources();
|
|
@@ -622,8 +695,8 @@ async function synthesizeSsml(ssml, config) {
|
|
|
622
695
|
resolve({
|
|
623
696
|
audioData: result.audioData,
|
|
624
697
|
durationMs,
|
|
625
|
-
audioSpec
|
|
626
|
-
mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
|
|
698
|
+
audioSpec,
|
|
699
|
+
mimeType: config.outputMimeType ?? resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
|
|
627
700
|
...config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {},
|
|
628
701
|
...requestId ? { requestId } : {},
|
|
629
702
|
...sourceBoundaries.length > 0 ? { boundaries: sourceBoundaries, wordBoundary: sourceBoundaries, wordBoundaries: sourceBoundaries } : {},
|
|
@@ -636,7 +709,7 @@ async function synthesizeSsml(ssml, config) {
|
|
|
636
709
|
abortHandler = () => rejectWithError(new SynthesisCancelledError());
|
|
637
710
|
config.signal.addEventListener("abort", abortHandler, { once: true });
|
|
638
711
|
}
|
|
639
|
-
const timeoutMs = config.timeouts?.perChunkMs ?? config.timeoutMs;
|
|
712
|
+
const timeoutMs = config.timeouts?.perChunkMs ?? config.timeouts?.totalJobMs ?? config.timeoutMs;
|
|
640
713
|
if (timeoutMs !== void 0 && timeoutMs > 0) {
|
|
641
714
|
timeout = setTimeout(
|
|
642
715
|
() => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
|
|
@@ -689,7 +762,7 @@ async function waitForRetry(delayMs, signal) {
|
|
|
689
762
|
}
|
|
690
763
|
});
|
|
691
764
|
}
|
|
692
|
-
async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
|
|
765
|
+
async function synthesizeWithRetry(ssml, config, retryOptions, onRetry, deadlineAtMs) {
|
|
693
766
|
const options = retryOptions ? {
|
|
694
767
|
maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
|
|
695
768
|
initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
|
|
@@ -700,17 +773,29 @@ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
|
|
|
700
773
|
while (true) {
|
|
701
774
|
if (config.signal?.aborted) throw new SynthesisCancelledError();
|
|
702
775
|
try {
|
|
703
|
-
return await
|
|
776
|
+
return await synthesizeSsmlOnce(ssml, config);
|
|
704
777
|
} catch (error) {
|
|
705
778
|
if (!options || attempt >= options.maxRetries || !(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error)))
|
|
706
779
|
throw error;
|
|
707
780
|
attempt += 1;
|
|
708
781
|
const delayMs = retryDelay(options, attempt, error);
|
|
782
|
+
const remainingMs = deadlineAtMs === void 0 ? void 0 : Math.max(0, deadlineAtMs - Date.now());
|
|
783
|
+
if (getRetryAfterDelayMs(error) !== void 0 && (delayMs > options.maxDelayMs || remainingMs !== void 0 && delayMs > remainingMs)) {
|
|
784
|
+
throw new SynthesisTimeoutError(
|
|
785
|
+
remainingMs === void 0 ? `Retry-After exceeded maxDelayMs (${options.maxDelayMs} ms).` : `Retry-After exceeded the remaining total job timeout (${remainingMs} ms).`
|
|
786
|
+
);
|
|
787
|
+
}
|
|
709
788
|
onRetry(attempt, delayMs);
|
|
710
|
-
await waitForRetry(delayMs, config.signal);
|
|
789
|
+
await waitForRetry(Math.min(delayMs, remainingMs ?? delayMs), config.signal);
|
|
711
790
|
}
|
|
712
791
|
}
|
|
713
792
|
}
|
|
793
|
+
async function synthesizeSsml(ssml, config) {
|
|
794
|
+
const totalJobMs = config.timeouts?.totalJobMs;
|
|
795
|
+
const deadlineAtMs = totalJobMs !== void 0 && totalJobMs > 0 ? Date.now() + totalJobMs : void 0;
|
|
796
|
+
if (!config.retryOptions) return synthesizeSsmlOnce(ssml, config);
|
|
797
|
+
return synthesizeWithRetry(ssml, config, config.retryOptions, () => void 0, deadlineAtMs);
|
|
798
|
+
}
|
|
714
799
|
function createAbortScope(parent, timeoutMs) {
|
|
715
800
|
const controller = new AbortController();
|
|
716
801
|
let didTimeout = false;
|
|
@@ -731,10 +816,10 @@ function createAbortScope(parent, timeoutMs) {
|
|
|
731
816
|
abort: () => controller.abort()
|
|
732
817
|
};
|
|
733
818
|
}
|
|
734
|
-
async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs, onRetry) {
|
|
819
|
+
async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs, onRetry, deadlineAtMs) {
|
|
735
820
|
const scope = createAbortScope(config.signal, timeoutMs);
|
|
736
821
|
try {
|
|
737
|
-
return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry);
|
|
822
|
+
return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry, deadlineAtMs);
|
|
738
823
|
} catch (error) {
|
|
739
824
|
if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
|
|
740
825
|
throw error;
|
|
@@ -743,18 +828,36 @@ async function synthesizeChunkWithTimeout(ssml, config, retryOptions, timeoutMs,
|
|
|
743
828
|
}
|
|
744
829
|
}
|
|
745
830
|
async function synthesizeSsmlChunks(chunks, config) {
|
|
746
|
-
const results = new Array(chunks.length);
|
|
747
831
|
const totalChunks = chunks.length;
|
|
832
|
+
const inputs = chunks.map((chunk) => typeof chunk === "string" ? { ssml: chunk } : chunk);
|
|
833
|
+
const fingerprints = inputs.map(
|
|
834
|
+
(chunk) => computeChunkFingerprint(chunk.ssml, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT)
|
|
835
|
+
);
|
|
836
|
+
const results = new Array(totalChunks);
|
|
748
837
|
const cachedChunks = new Map((config.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
|
|
838
|
+
const invalidCachedIndices = /* @__PURE__ */ new Set();
|
|
839
|
+
const chunkStates = inputs.map((_chunk, chunkIndex) => ({
|
|
840
|
+
chunkIndex,
|
|
841
|
+
status: "pending",
|
|
842
|
+
canResume: true
|
|
843
|
+
}));
|
|
749
844
|
for (const [index, cached] of cachedChunks) {
|
|
750
|
-
if (index
|
|
845
|
+
if (index < 0 || index >= totalChunks) continue;
|
|
846
|
+
const isValid = config.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index];
|
|
847
|
+
if (isValid) {
|
|
848
|
+
results[index] = { ...cached };
|
|
849
|
+
chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
|
|
850
|
+
} else {
|
|
851
|
+
invalidCachedIndices.add(index);
|
|
852
|
+
}
|
|
751
853
|
}
|
|
752
854
|
const requestedIndices = config.resumeChunkIndices ? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks)) : void 0;
|
|
753
|
-
const shouldSynthesize = (index) => !cachedChunks.has(index) && (requestedIndices === void 0 || requestedIndices.has(index));
|
|
855
|
+
const shouldSynthesize = (index) => (!cachedChunks.has(index) || invalidCachedIndices.has(index)) && (requestedIndices === void 0 || requestedIndices.has(index) || invalidCachedIndices.has(index));
|
|
856
|
+
const jobStartedAt = Date.now();
|
|
857
|
+
const jobDeadlineAt = config.timeouts?.totalJobMs !== void 0 && config.timeouts.totalJobMs > 0 ? jobStartedAt + config.timeouts.totalJobMs : void 0;
|
|
754
858
|
const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
|
|
755
859
|
const report = (event) => config.onProgress?.(event);
|
|
756
|
-
for (const [index,
|
|
757
|
-
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
860
|
+
for (const [index, input] of inputs.entries()) {
|
|
758
861
|
report({
|
|
759
862
|
currentChunk: index,
|
|
760
863
|
totalChunks,
|
|
@@ -776,8 +879,7 @@ async function synthesizeSsmlChunks(chunks, config) {
|
|
|
776
879
|
if (index >= chunks.length) return;
|
|
777
880
|
if (!shouldSynthesize(index)) continue;
|
|
778
881
|
if (firstError && config.cancelOnFailure !== false) return;
|
|
779
|
-
const
|
|
780
|
-
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
882
|
+
const input = inputs[index];
|
|
781
883
|
report({
|
|
782
884
|
currentChunk: completed,
|
|
783
885
|
totalChunks,
|
|
@@ -814,9 +916,11 @@ async function synthesizeSsmlChunks(chunks, config) {
|
|
|
814
916
|
retryAttempt,
|
|
815
917
|
nextRetryDelayMs,
|
|
816
918
|
isRetrying: true
|
|
817
|
-
})
|
|
919
|
+
}),
|
|
920
|
+
jobDeadlineAt
|
|
818
921
|
);
|
|
819
922
|
results[index] = result;
|
|
923
|
+
chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result };
|
|
820
924
|
completed += 1;
|
|
821
925
|
report({
|
|
822
926
|
currentChunk: completed,
|
|
@@ -828,7 +932,16 @@ async function synthesizeSsmlChunks(chunks, config) {
|
|
|
828
932
|
durationMs: Date.now() - startedAt
|
|
829
933
|
});
|
|
830
934
|
} catch (error) {
|
|
831
|
-
|
|
935
|
+
const wasCancelled = firstError !== void 0 || scope.signal.aborted && !scope.timedOut();
|
|
936
|
+
firstError ?? (firstError = scope.timedOut() ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`) : error);
|
|
937
|
+
if (!wasCancelled) failedIndices.add(index);
|
|
938
|
+
chunkStates[index] = {
|
|
939
|
+
chunkIndex: index,
|
|
940
|
+
status: wasCancelled ? "cancelled" : "failed",
|
|
941
|
+
isOriginalFailure: !wasCancelled,
|
|
942
|
+
canResume: true,
|
|
943
|
+
error
|
|
944
|
+
};
|
|
832
945
|
report({
|
|
833
946
|
currentChunk: completed,
|
|
834
947
|
totalChunks,
|
|
@@ -839,7 +952,6 @@ async function synthesizeSsmlChunks(chunks, config) {
|
|
|
839
952
|
durationMs: Date.now() - startedAt,
|
|
840
953
|
error
|
|
841
954
|
});
|
|
842
|
-
firstError ?? (firstError = scope.timedOut() ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`) : error);
|
|
843
955
|
if (config.cancelOnFailure !== false) scope.abort();
|
|
844
956
|
return;
|
|
845
957
|
}
|
|
@@ -857,11 +969,25 @@ async function synthesizeSsmlChunks(chunks, config) {
|
|
|
857
969
|
postMergeValidator: config.postMergeValidator
|
|
858
970
|
});
|
|
859
971
|
} catch (error) {
|
|
972
|
+
if (firstError && config.cancelOnFailure !== false) {
|
|
973
|
+
for (const [chunkIndex, state] of chunkStates.entries()) {
|
|
974
|
+
if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
|
|
975
|
+
chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
const synthesizedChunks = results.flatMap(
|
|
980
|
+
(result, chunkIndex) => result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : []
|
|
981
|
+
);
|
|
860
982
|
const partial = {
|
|
861
|
-
synthesizedChunks
|
|
862
|
-
completedChunks:
|
|
863
|
-
pendingChunkIndices:
|
|
983
|
+
synthesizedChunks,
|
|
984
|
+
completedChunks: synthesizedChunks,
|
|
985
|
+
pendingChunkIndices: chunkStates.flatMap(
|
|
986
|
+
(state) => state.status === "pending" || state.status === "cancelled" || state.status === "failed" ? [state.chunkIndex] : []
|
|
987
|
+
),
|
|
864
988
|
failedChunkIndices: [...failedIndices],
|
|
989
|
+
cancelledChunkIndices: chunkStates.filter((state) => state.status === "cancelled").map((state) => state.chunkIndex),
|
|
990
|
+
chunkStates,
|
|
865
991
|
totalChunks
|
|
866
992
|
};
|
|
867
993
|
if (error && typeof error === "object") error.partialResult = partial;
|
|
@@ -959,16 +1085,15 @@ function mergeSynthesisResults(results, options) {
|
|
|
959
1085
|
})
|
|
960
1086
|
).then((merged) => {
|
|
961
1087
|
if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
|
|
962
|
-
if (!(merged instanceof ArrayBuffer) || buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
|
|
963
|
-
throw new MergeError("The custom audio merger returned an invalid audio buffer.");
|
|
964
1088
|
if (signal.aborted) throw new SynthesisCancelledError();
|
|
965
|
-
const
|
|
966
|
-
results,
|
|
1089
|
+
const mergedSpec = validateMergedAudioBuffer(
|
|
967
1090
|
merged,
|
|
968
1091
|
format,
|
|
969
|
-
|
|
970
|
-
|
|
1092
|
+
buffers,
|
|
1093
|
+
inputSpecs,
|
|
1094
|
+
resolvedOptions.outputMimeType ?? resolveMimeType(format)
|
|
971
1095
|
);
|
|
1096
|
+
const result = createMergedResult(results, merged, format, mergedSpec, resolvedOptions.outputMimeType);
|
|
972
1097
|
return Promise.resolve(
|
|
973
1098
|
resolvedOptions.postMergeValidator?.(result, {
|
|
974
1099
|
format,
|
|
@@ -1101,7 +1226,7 @@ function resolveConcurrency2(value, total) {
|
|
|
1101
1226
|
if (value === Infinity) return Math.max(1, total);
|
|
1102
1227
|
return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
|
|
1103
1228
|
}
|
|
1104
|
-
async function retryableSynthesis(synthesize, options, signal, onRetry) {
|
|
1229
|
+
async function retryableSynthesis(synthesize, options, signal, onRetry, deadlineAtMs) {
|
|
1105
1230
|
const retry = options ? {
|
|
1106
1231
|
maxRetries: Math.max(0, Math.floor(options.maxRetries)),
|
|
1107
1232
|
initialDelayMs: options.initialDelayMs,
|
|
@@ -1118,6 +1243,11 @@ async function retryableSynthesis(synthesize, options, signal, onRetry) {
|
|
|
1118
1243
|
throw error;
|
|
1119
1244
|
attempt += 1;
|
|
1120
1245
|
const delayMs = retryDelayForError(retry, attempt, error);
|
|
1246
|
+
const retryAfterMs = getRetryAfterDelayMs(error);
|
|
1247
|
+
const remainingMs = deadlineAtMs === void 0 ? void 0 : Math.max(0, deadlineAtMs - Date.now());
|
|
1248
|
+
if (retryAfterMs !== void 0 && (retryAfterMs > retry.maxDelayMs || remainingMs !== void 0 && retryAfterMs > remainingMs)) {
|
|
1249
|
+
throw new Error("Speech synthesis timed out because Retry-After exceeded the available retry budget.");
|
|
1250
|
+
}
|
|
1121
1251
|
onRetry(attempt, delayMs);
|
|
1122
1252
|
if (delayMs > 0)
|
|
1123
1253
|
await new Promise((resolve, reject) => {
|
|
@@ -1165,20 +1295,24 @@ async function synthesizeSsmlSafe(client, ssml, options = {}) {
|
|
|
1165
1295
|
diagnostics: errors
|
|
1166
1296
|
});
|
|
1167
1297
|
}
|
|
1298
|
+
const jobScope = options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts.totalJobMs) : void 0;
|
|
1168
1299
|
try {
|
|
1169
1300
|
return {
|
|
1170
1301
|
ok: true,
|
|
1171
1302
|
success: true,
|
|
1172
1303
|
status: "success",
|
|
1173
1304
|
value: await client.synthesizeSsml(ssml, {
|
|
1174
|
-
signal: options.signal,
|
|
1305
|
+
signal: jobScope?.signal ?? options.signal,
|
|
1175
1306
|
timeoutMs: options.timeouts?.perChunkMs,
|
|
1176
1307
|
timeouts: options.timeouts
|
|
1177
1308
|
})
|
|
1178
1309
|
};
|
|
1179
1310
|
} catch (error) {
|
|
1311
|
+
if (jobScope?.timedOut()) return failure(toSynthesisError(new Error("Speech synthesis timed out.")));
|
|
1180
1312
|
const synthesisError = toSynthesisError(error);
|
|
1181
1313
|
return failure(synthesisError);
|
|
1314
|
+
} finally {
|
|
1315
|
+
jobScope?.dispose();
|
|
1182
1316
|
}
|
|
1183
1317
|
}
|
|
1184
1318
|
async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
|
|
@@ -1250,17 +1384,32 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
|
|
|
1250
1384
|
resumeChunkIndices: options.resumeChunkIndices,
|
|
1251
1385
|
customMerger: options.customMerger,
|
|
1252
1386
|
outputMimeType: options.outputMimeType,
|
|
1253
|
-
postMergeValidator: options.postMergeValidator
|
|
1387
|
+
postMergeValidator: options.postMergeValidator,
|
|
1388
|
+
resumeValidation: options.resumeValidation
|
|
1254
1389
|
});
|
|
1255
1390
|
return { ok: true, success: true, status: "success", value };
|
|
1256
1391
|
}
|
|
1392
|
+
const inputs = chunks.map((chunk) => typeof chunk === "string" ? { ssml: chunk } : chunk);
|
|
1393
|
+
const fingerprints = inputs.map((chunk) => computeChunkFingerprint(chunk.ssml, options.outputFormat));
|
|
1257
1394
|
const results = new Array(chunks.length);
|
|
1395
|
+
const chunkStates = inputs.map((_chunk, chunkIndex) => ({
|
|
1396
|
+
chunkIndex,
|
|
1397
|
+
status: "pending",
|
|
1398
|
+
canResume: true
|
|
1399
|
+
}));
|
|
1258
1400
|
const cachedChunks = new Map((options.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
|
|
1401
|
+
const invalidCachedIndices = /* @__PURE__ */ new Set();
|
|
1259
1402
|
for (const [index, cached] of cachedChunks) {
|
|
1260
|
-
if (index
|
|
1403
|
+
if (index < 0 || index >= chunks.length) continue;
|
|
1404
|
+
if (options.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index]) {
|
|
1405
|
+
results[index] = cached;
|
|
1406
|
+
chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: cached };
|
|
1407
|
+
} else invalidCachedIndices.add(index);
|
|
1261
1408
|
}
|
|
1262
1409
|
const requestedIndices = options.resumeChunkIndices ? new Set(options.resumeChunkIndices.filter((index) => index >= 0 && index < chunks.length)) : void 0;
|
|
1263
|
-
const shouldSynthesize = (index) => !cachedChunks.has(index) && (requestedIndices === void 0 || requestedIndices.has(index));
|
|
1410
|
+
const shouldSynthesize = (index) => (!cachedChunks.has(index) || invalidCachedIndices.has(index)) && (requestedIndices === void 0 || requestedIndices.has(index) || invalidCachedIndices.has(index));
|
|
1411
|
+
const jobStartedAt = Date.now();
|
|
1412
|
+
const jobDeadlineAt = options.timeouts?.totalJobMs !== void 0 && options.timeouts.totalJobMs > 0 ? jobStartedAt + options.timeouts.totalJobMs : void 0;
|
|
1264
1413
|
const jobScope = chunks.length > 1 || options.timeouts?.totalJobMs !== void 0 ? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs) : void 0;
|
|
1265
1414
|
fallbackJobScope = jobScope;
|
|
1266
1415
|
const failedIndices = /* @__PURE__ */ new Set();
|
|
@@ -1273,7 +1422,10 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
|
|
|
1273
1422
|
const index = nextIndex++;
|
|
1274
1423
|
if (index >= chunks.length) return;
|
|
1275
1424
|
if (!shouldSynthesize(index)) continue;
|
|
1276
|
-
if (
|
|
1425
|
+
if (firstError && options.cancelOnFailure !== false) {
|
|
1426
|
+
chunkStates[index] = { chunkIndex: index, status: "cancelled", isOriginalFailure: false, canResume: true };
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1277
1429
|
const chunk = chunks[index];
|
|
1278
1430
|
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
1279
1431
|
const sourceNodePath = input.sourceNodePath;
|
|
@@ -1306,7 +1458,8 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
|
|
|
1306
1458
|
retryAttempt,
|
|
1307
1459
|
nextRetryDelayMs,
|
|
1308
1460
|
isRetrying: true
|
|
1309
|
-
})
|
|
1461
|
+
}),
|
|
1462
|
+
jobDeadlineAt
|
|
1310
1463
|
);
|
|
1311
1464
|
} catch (error) {
|
|
1312
1465
|
if (chunkScope?.timedOut())
|
|
@@ -1358,6 +1511,7 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
|
|
|
1358
1511
|
}))
|
|
1359
1512
|
} : {}
|
|
1360
1513
|
};
|
|
1514
|
+
chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
|
|
1361
1515
|
completed += 1;
|
|
1362
1516
|
options.onProgress?.({
|
|
1363
1517
|
currentChunk: completed,
|
|
@@ -1369,7 +1523,16 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
|
|
|
1369
1523
|
durationMs: Date.now() - startedAt
|
|
1370
1524
|
});
|
|
1371
1525
|
} catch (error) {
|
|
1372
|
-
|
|
1526
|
+
const wasCancelled = firstError !== void 0 || Boolean(jobScope?.signal.aborted && !jobScope?.timedOut());
|
|
1527
|
+
firstError ?? (firstError = error);
|
|
1528
|
+
if (!wasCancelled) failedIndices.add(index);
|
|
1529
|
+
chunkStates[index] = {
|
|
1530
|
+
chunkIndex: index,
|
|
1531
|
+
status: wasCancelled ? "cancelled" : "failed",
|
|
1532
|
+
isOriginalFailure: !wasCancelled,
|
|
1533
|
+
canResume: true,
|
|
1534
|
+
error
|
|
1535
|
+
};
|
|
1373
1536
|
options.onProgress?.({
|
|
1374
1537
|
currentChunk: completed,
|
|
1375
1538
|
totalChunks: chunks.length,
|
|
@@ -1381,19 +1544,32 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
|
|
|
1381
1544
|
error
|
|
1382
1545
|
});
|
|
1383
1546
|
if (options.cancelOnFailure !== false) jobScope?.abort();
|
|
1384
|
-
firstError ?? (firstError = error);
|
|
1385
1547
|
return;
|
|
1386
1548
|
}
|
|
1387
1549
|
}
|
|
1388
1550
|
};
|
|
1389
1551
|
await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
|
|
1552
|
+
if (firstError && options.cancelOnFailure !== false) {
|
|
1553
|
+
for (const [chunkIndex, state] of chunkStates.entries()) {
|
|
1554
|
+
if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
|
|
1555
|
+
chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1390
1559
|
if (failedIndices.size > 0) {
|
|
1391
1560
|
const error = firstError ?? new Error("One or more SSML chunks failed to synthesize.");
|
|
1561
|
+
const synthesizedChunks = results.flatMap(
|
|
1562
|
+
(result, chunkIndex) => result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : []
|
|
1563
|
+
);
|
|
1392
1564
|
error.partialResult = {
|
|
1393
|
-
synthesizedChunks
|
|
1394
|
-
completedChunks:
|
|
1395
|
-
pendingChunkIndices:
|
|
1565
|
+
synthesizedChunks,
|
|
1566
|
+
completedChunks: synthesizedChunks,
|
|
1567
|
+
pendingChunkIndices: chunkStates.flatMap(
|
|
1568
|
+
(state) => state.status === "pending" || state.status === "cancelled" || state.status === "failed" ? [state.chunkIndex] : []
|
|
1569
|
+
),
|
|
1396
1570
|
failedChunkIndices: [...failedIndices],
|
|
1571
|
+
cancelledChunkIndices: chunkStates.filter((state) => state.status === "cancelled").map((state) => state.chunkIndex),
|
|
1572
|
+
chunkStates,
|
|
1397
1573
|
totalChunks: chunks.length
|
|
1398
1574
|
};
|
|
1399
1575
|
throw error;
|
|
@@ -1439,7 +1615,16 @@ var AzureTtsClient = class {
|
|
|
1439
1615
|
const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = __privateGet(this, _options);
|
|
1440
1616
|
const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
|
|
1441
1617
|
__privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
|
|
1442
|
-
const config = {
|
|
1618
|
+
const config = {
|
|
1619
|
+
endpoint,
|
|
1620
|
+
region,
|
|
1621
|
+
subscriptionKey,
|
|
1622
|
+
outputFormat,
|
|
1623
|
+
signal,
|
|
1624
|
+
timeoutMs,
|
|
1625
|
+
timeouts,
|
|
1626
|
+
retryOptions: __privateGet(this, _options).retryOptions
|
|
1627
|
+
};
|
|
1443
1628
|
return synthesizeSpeech(ssml, config);
|
|
1444
1629
|
}
|
|
1445
1630
|
async synthesizeSsml(ssml, options = {}) {
|
|
@@ -1456,7 +1641,13 @@ var AzureTtsClient = class {
|
|
|
1456
1641
|
timeouts: options.timeouts ?? timeouts,
|
|
1457
1642
|
sourceNodePath: options.sourceNodePath,
|
|
1458
1643
|
sourceTextSegments: options.sourceTextSegments,
|
|
1459
|
-
sourceMarkers: options.sourceMarkers
|
|
1644
|
+
sourceMarkers: options.sourceMarkers,
|
|
1645
|
+
retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
|
|
1646
|
+
cancelOnFailure: options.cancelOnFailure ?? __privateGet(this, _options).cancelOnFailure,
|
|
1647
|
+
customMerger: options.customMerger ?? __privateGet(this, _options).customMerger,
|
|
1648
|
+
outputMimeType: options.outputMimeType ?? __privateGet(this, _options).outputMimeType,
|
|
1649
|
+
postMergeValidator: options.postMergeValidator ?? __privateGet(this, _options).postMergeValidator,
|
|
1650
|
+
resumeValidation: options.resumeValidation ?? __privateGet(this, _options).resumeValidation
|
|
1460
1651
|
});
|
|
1461
1652
|
}
|
|
1462
1653
|
async synthesizeChunks(chunks, options = {}) {
|
|
@@ -1474,12 +1665,13 @@ var AzureTtsClient = class {
|
|
|
1474
1665
|
onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
|
|
1475
1666
|
concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
|
|
1476
1667
|
retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions,
|
|
1477
|
-
cancelOnFailure: options.cancelOnFailure,
|
|
1668
|
+
cancelOnFailure: options.cancelOnFailure ?? __privateGet(this, _options).cancelOnFailure,
|
|
1478
1669
|
resumeChunks: options.resumeChunks,
|
|
1479
1670
|
resumeChunkIndices: options.resumeChunkIndices,
|
|
1480
|
-
customMerger: options.customMerger,
|
|
1481
|
-
outputMimeType: options.outputMimeType,
|
|
1482
|
-
postMergeValidator: options.postMergeValidator
|
|
1671
|
+
customMerger: options.customMerger ?? __privateGet(this, _options).customMerger,
|
|
1672
|
+
outputMimeType: options.outputMimeType ?? __privateGet(this, _options).outputMimeType,
|
|
1673
|
+
postMergeValidator: options.postMergeValidator ?? __privateGet(this, _options).postMergeValidator,
|
|
1674
|
+
resumeValidation: options.resumeValidation ?? __privateGet(this, _options).resumeValidation
|
|
1483
1675
|
});
|
|
1484
1676
|
}
|
|
1485
1677
|
async synthesizeSsmlSafe(ssml, options = {}) {
|
|
@@ -1604,6 +1796,7 @@ export {
|
|
|
1604
1796
|
SynthesisTimeoutError,
|
|
1605
1797
|
UnsupportedMergeFormatError,
|
|
1606
1798
|
canMergeAudioFormat,
|
|
1799
|
+
computeChunkFingerprint,
|
|
1607
1800
|
fetchAzureVoiceCatalog,
|
|
1608
1801
|
getRetryAfterDelayMs,
|
|
1609
1802
|
inspectAudioSpecification,
|