@ssml-builder-js/azure-tts-client 2.18.0 → 2.19.1
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 +18 -0
- package/LICENSE +21 -0
- package/dist/index.js +453 -63
- package/package.json +8 -8
- package/src/client.ts +43 -3
- package/src/deadline.ts +52 -0
- package/src/errors.ts +86 -4
- package/src/index.ts +11 -1
- package/src/safe.ts +52 -31
- package/src/synthesis.ts +307 -22
- package/src/types.ts +8 -3
- package/test/synthesis.test.ts +20 -0
- package/test/v219-pipeline.test.ts +265 -0
- package/dist/index.d.mts +0 -487
- package/dist/index.d.ts +0 -487
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -1813
- package/dist/index.mjs.map +0 -1
package/src/synthesis.ts
CHANGED
|
@@ -24,6 +24,8 @@ import type {
|
|
|
24
24
|
PostMergeValidator,
|
|
25
25
|
ChunkExecutionState,
|
|
26
26
|
} from "./types.ts";
|
|
27
|
+
import { DeadlineController } from "./deadline.ts";
|
|
28
|
+
import { IncompleteChunkSetError, serializeChunkError } from "./errors.ts";
|
|
27
29
|
|
|
28
30
|
export type MergeAudioFormat = "wav" | "mp3" | "raw";
|
|
29
31
|
|
|
@@ -40,18 +42,38 @@ export type InputAudioSpecs = AudioSpecification[];
|
|
|
40
42
|
* The complete SSML is included so changes to voice, language, prosody, or text
|
|
41
43
|
* invalidate a cached result even when those settings are nested in the markup.
|
|
42
44
|
*/
|
|
43
|
-
export
|
|
45
|
+
export interface ChunkFingerprintOptions {
|
|
46
|
+
outputFormat?: string;
|
|
47
|
+
region?: string;
|
|
48
|
+
endpoint?: string;
|
|
49
|
+
voice?: string;
|
|
50
|
+
lang?: string;
|
|
51
|
+
schemaVersion?: string;
|
|
52
|
+
fingerprintSchemaVersion?: string;
|
|
53
|
+
customHeaders?: Readonly<Record<string, string>>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function computeChunkFingerprint(
|
|
57
|
+
ssml: string,
|
|
58
|
+
outputFormat = DEFAULT_OUTPUT_FORMAT,
|
|
59
|
+
options: Omit<ChunkFingerprintOptions, "outputFormat"> = {},
|
|
60
|
+
): string {
|
|
44
61
|
const readAttribute = (name: string): string => {
|
|
45
62
|
const pattern = new RegExp(`(?:${name})\\s*=\\s*[\\"']([^\\"']*)`, "gi");
|
|
46
63
|
return [...ssml.matchAll(pattern)].map((match) => match[1] ?? "").join("|");
|
|
47
64
|
};
|
|
65
|
+
const headers = Object.fromEntries(
|
|
66
|
+
Object.entries(options.customHeaders ?? {}).sort(([first], [second]) => first.localeCompare(second)),
|
|
67
|
+
);
|
|
48
68
|
const payload = JSON.stringify({
|
|
49
69
|
ssml,
|
|
50
70
|
outputFormat,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
71
|
+
region: options.region ?? "",
|
|
72
|
+
endpoint: options.endpoint ?? "",
|
|
73
|
+
voice: options.voice ?? readAttribute("(?:name|voice)"),
|
|
74
|
+
lang: options.lang ?? readAttribute("(?:xml:lang|lang)"),
|
|
75
|
+
customHeaders: headers,
|
|
76
|
+
fingerprintSchemaVersion: options.fingerprintSchemaVersion ?? options.schemaVersion ?? "2",
|
|
55
77
|
});
|
|
56
78
|
let hash = 0xcbf29ce484222325n;
|
|
57
79
|
const mask = 0xffffffffffffffffn;
|
|
@@ -65,6 +87,7 @@ export function computeChunkFingerprint(ssml: string, outputFormat = DEFAULT_OUT
|
|
|
65
87
|
export interface MergeSynthesisOptions extends MergeAudioOptions {
|
|
66
88
|
customMerger?: CustomAudioMerger;
|
|
67
89
|
postMergeValidator?: PostMergeValidator;
|
|
90
|
+
deadline?: DeadlineController;
|
|
68
91
|
}
|
|
69
92
|
|
|
70
93
|
type AsyncMergeSynthesisOptions = MergeSynthesisOptions & {
|
|
@@ -223,6 +246,143 @@ function parseMp3Specification(buffer: ArrayBuffer, format: string): AudioSpecif
|
|
|
223
246
|
return undefined;
|
|
224
247
|
}
|
|
225
248
|
|
|
249
|
+
function readEbmlVint(bytes: Uint8Array, offset: number, preserveMarker: boolean): { value: number; length: number } {
|
|
250
|
+
const first = bytes[offset];
|
|
251
|
+
if (first === undefined) throw new Error("Invalid EBML variable-length integer.");
|
|
252
|
+
let mask = 0x80;
|
|
253
|
+
let length = 1;
|
|
254
|
+
while (length <= 8 && (first & mask) === 0) {
|
|
255
|
+
mask >>= 1;
|
|
256
|
+
length += 1;
|
|
257
|
+
}
|
|
258
|
+
if (length > 8 || offset + length > bytes.byteLength) throw new Error("Truncated EBML variable-length integer.");
|
|
259
|
+
let value = preserveMarker ? first : first & (mask - 1);
|
|
260
|
+
for (let index = 1; index < length; index += 1) value = value * 256 + (bytes[offset + index] ?? 0);
|
|
261
|
+
if (!preserveMarker && value === 2 ** (7 * length) - 1)
|
|
262
|
+
throw new Error("EBML unknown-size elements are not supported.");
|
|
263
|
+
return { value, length };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
interface EbmlElement {
|
|
267
|
+
id: number;
|
|
268
|
+
dataStart: number;
|
|
269
|
+
dataEnd: number;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function readEbmlElement(bytes: Uint8Array, offset: number): EbmlElement {
|
|
273
|
+
const id = readEbmlVint(bytes, offset, true);
|
|
274
|
+
const size = readEbmlVint(bytes, offset + id.length, false);
|
|
275
|
+
const dataStart = offset + id.length + size.length;
|
|
276
|
+
const dataEnd = dataStart + size.value;
|
|
277
|
+
if (dataEnd > bytes.byteLength) throw new Error("EBML element exceeds the audio buffer.");
|
|
278
|
+
return { id: id.value, dataStart, dataEnd };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function ebmlText(bytes: Uint8Array, element: EbmlElement): string {
|
|
282
|
+
return new TextDecoder().decode(bytes.slice(element.dataStart, element.dataEnd));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function findEbmlElement(bytes: Uint8Array, start: number, end: number, id: number): EbmlElement | undefined {
|
|
286
|
+
let offset = start;
|
|
287
|
+
while (offset < end) {
|
|
288
|
+
const element = readEbmlElement(bytes, offset);
|
|
289
|
+
if (element.id === id) return element;
|
|
290
|
+
offset = element.dataEnd;
|
|
291
|
+
}
|
|
292
|
+
if (offset !== end) throw new Error("Invalid EBML element boundary.");
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function parseOggSpecification(buffer: ArrayBuffer, format: string): AudioSpecification {
|
|
297
|
+
const bytes = new Uint8Array(buffer);
|
|
298
|
+
let offset = 0;
|
|
299
|
+
let firstPayload: Uint8Array | undefined;
|
|
300
|
+
let pages = 0;
|
|
301
|
+
while (offset < bytes.byteLength) {
|
|
302
|
+
if (offset + 27 > bytes.byteLength || !ascii(bytes, offset, "OggS")) throw new Error("Invalid Ogg page header.");
|
|
303
|
+
if (bytes[offset + 4] !== 0) throw new Error("Unsupported Ogg bitstream version.");
|
|
304
|
+
const segmentCount = bytes[offset + 26] ?? 0;
|
|
305
|
+
const lacingStart = offset + 27;
|
|
306
|
+
const payloadStart = lacingStart + segmentCount;
|
|
307
|
+
if (payloadStart > bytes.byteLength) throw new Error("Truncated Ogg segment table.");
|
|
308
|
+
const payloadLength = bytes.slice(lacingStart, payloadStart).reduce((total, value) => total + value, 0);
|
|
309
|
+
const pageEnd = payloadStart + payloadLength;
|
|
310
|
+
if (pageEnd > bytes.byteLength) throw new Error("Ogg page payload exceeds the audio buffer.");
|
|
311
|
+
if (pages === 0) firstPayload = bytes.slice(payloadStart, pageEnd);
|
|
312
|
+
offset = pageEnd;
|
|
313
|
+
pages += 1;
|
|
314
|
+
}
|
|
315
|
+
if (pages === 0 || !firstPayload || !ascii(firstPayload, 0, "OpusHead") || firstPayload.byteLength < 19)
|
|
316
|
+
throw new Error("Ogg audio must contain a valid OpusHead packet.");
|
|
317
|
+
const version = firstPayload[8];
|
|
318
|
+
const channels = firstPayload[9] ?? 0;
|
|
319
|
+
const sampleRate = new DataView(firstPayload.buffer, firstPayload.byteOffset, firstPayload.byteLength).getUint32(
|
|
320
|
+
12,
|
|
321
|
+
true,
|
|
322
|
+
);
|
|
323
|
+
if (version !== 1 || channels <= 0 || sampleRate <= 0) throw new Error("Invalid Ogg OpusHead stream parameters.");
|
|
324
|
+
return {
|
|
325
|
+
format,
|
|
326
|
+
mimeType: "audio/ogg",
|
|
327
|
+
codec: "opus",
|
|
328
|
+
sampleRate,
|
|
329
|
+
channels,
|
|
330
|
+
container: "ogg",
|
|
331
|
+
isVbr: true,
|
|
332
|
+
isCompressed: true,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function parseWebmSpecification(buffer: ArrayBuffer, format: string): AudioSpecification {
|
|
337
|
+
const bytes = new Uint8Array(buffer);
|
|
338
|
+
const ebml = readEbmlElement(bytes, 0);
|
|
339
|
+
if (ebml.id !== 0x1a45dfa3) throw new Error("WebM audio must begin with an EBML header.");
|
|
340
|
+
const docType = findEbmlElement(bytes, ebml.dataStart, ebml.dataEnd, 0x4282);
|
|
341
|
+
if (!docType || ebmlText(bytes, docType).toLowerCase() !== "webm") throw new Error("EBML DocType must be webm.");
|
|
342
|
+
const segment = readEbmlElement(bytes, ebml.dataEnd);
|
|
343
|
+
if (segment.id !== 0x18538067) throw new Error("WebM audio must contain a Segment element.");
|
|
344
|
+
const tracks = findEbmlElement(bytes, segment.dataStart, segment.dataEnd, 0x1654ae6b);
|
|
345
|
+
if (!tracks) throw new Error("WebM audio must contain a Tracks element.");
|
|
346
|
+
let offset = tracks.dataStart;
|
|
347
|
+
let opusTrack: EbmlElement | undefined;
|
|
348
|
+
while (offset < tracks.dataEnd) {
|
|
349
|
+
const track = readEbmlElement(bytes, offset);
|
|
350
|
+
if (track.id === 0xae) {
|
|
351
|
+
const codec = findEbmlElement(bytes, track.dataStart, track.dataEnd, 0x86);
|
|
352
|
+
const trackType = findEbmlElement(bytes, track.dataStart, track.dataEnd, 0x83);
|
|
353
|
+
if (codec && ebmlText(bytes, codec) === "A_OPUS" && trackType && bytes[trackType.dataStart] === 2) {
|
|
354
|
+
opusTrack = track;
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
offset = track.dataEnd;
|
|
359
|
+
}
|
|
360
|
+
if (!opusTrack) throw new Error("WebM tracks do not define an Opus audio track.");
|
|
361
|
+
const audio = findEbmlElement(bytes, opusTrack.dataStart, opusTrack.dataEnd, 0xe1);
|
|
362
|
+
const sampling = audio ? findEbmlElement(bytes, audio.dataStart, audio.dataEnd, 0xb5) : undefined;
|
|
363
|
+
const channels = audio ? findEbmlElement(bytes, audio.dataStart, audio.dataEnd, 0x9f) : undefined;
|
|
364
|
+
const sampleRate = sampling
|
|
365
|
+
? new DataView(
|
|
366
|
+
bytes.buffer,
|
|
367
|
+
bytes.byteOffset + sampling.dataStart,
|
|
368
|
+
sampling.dataEnd - sampling.dataStart,
|
|
369
|
+
).getFloat64(0, false)
|
|
370
|
+
: 0;
|
|
371
|
+
const channelCount = channels ? (bytes[channels.dataEnd - 1] ?? 0) : 0;
|
|
372
|
+
if (!Number.isFinite(sampleRate) || sampleRate <= 0 || channelCount <= 0)
|
|
373
|
+
throw new Error("WebM Opus audio track has invalid sampling or channel parameters.");
|
|
374
|
+
return {
|
|
375
|
+
format,
|
|
376
|
+
mimeType: "audio/webm",
|
|
377
|
+
codec: "opus",
|
|
378
|
+
sampleRate: Math.round(sampleRate),
|
|
379
|
+
channels: channelCount,
|
|
380
|
+
container: "webm",
|
|
381
|
+
isVbr: true,
|
|
382
|
+
isCompressed: true,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
226
386
|
/** Extracts the stream specification from a WAV/MP3 header and output-format fallback. */
|
|
227
387
|
export function inspectAudioSpecification(buffer: ArrayBuffer, format: string): AudioSpecification {
|
|
228
388
|
if (isWavFormat(format) || ascii(new Uint8Array(buffer), 0, "RIFF")) {
|
|
@@ -257,7 +417,20 @@ export function inspectAudioSpecification(buffer: ArrayBuffer, format: string):
|
|
|
257
417
|
isCompressed: codec !== "pcm" && codec !== "unknown",
|
|
258
418
|
};
|
|
259
419
|
}
|
|
260
|
-
if (isMp3Format(format))
|
|
420
|
+
if (isMp3Format(format)) {
|
|
421
|
+
const specification = parseMp3Specification(buffer, format);
|
|
422
|
+
return specification ?? formatAudioSpecification(format);
|
|
423
|
+
}
|
|
424
|
+
if (isOggFormat(format) || ascii(new Uint8Array(buffer), 0, "OggS")) {
|
|
425
|
+
const specification = parseOggSpecification(buffer, format);
|
|
426
|
+
validateContainerFormat(specification, format);
|
|
427
|
+
return specification;
|
|
428
|
+
}
|
|
429
|
+
if (isWebmFormat(format) || new Uint8Array(buffer)[0] === 0x1a) {
|
|
430
|
+
const specification = parseWebmSpecification(buffer, format);
|
|
431
|
+
validateContainerFormat(specification, format);
|
|
432
|
+
return specification;
|
|
433
|
+
}
|
|
261
434
|
const specification = formatAudioSpecification(format);
|
|
262
435
|
if (specification.container === "raw") validateRawAudioBuffer(buffer, specification);
|
|
263
436
|
return specification;
|
|
@@ -267,7 +440,24 @@ function validateRawAudioBuffer(buffer: ArrayBuffer, specification: AudioSpecifi
|
|
|
267
440
|
if (specification.sampleRate <= 0 || specification.channels <= 0 || specification.bitDepth === undefined) {
|
|
268
441
|
throw new Error(`RAW audio format "${specification.format}" does not define a complete audio specification.`);
|
|
269
442
|
}
|
|
270
|
-
if (specification.codec === "siren"
|
|
443
|
+
if (specification.codec === "siren") return;
|
|
444
|
+
if (specification.codec === "silk") {
|
|
445
|
+
if (buffer.byteLength <= 9 || !ascii(new Uint8Array(buffer), 0, "#!SILK_V3"))
|
|
446
|
+
throw new Error("RAW SILK audio must contain a valid #!SILK_V3 payload header.");
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (specification.codec === "opus" && buffer.byteLength === 0) throw new Error("RAW Opus audio cannot be empty.");
|
|
450
|
+
if (specification.codec === "opus") {
|
|
451
|
+
const packetCode = new Uint8Array(buffer)[0] ?? 0;
|
|
452
|
+
const frameCountCode = packetCode & 0x03;
|
|
453
|
+
if (
|
|
454
|
+
packetCode >> 3 > 31 ||
|
|
455
|
+
buffer.byteLength < (frameCountCode === 3 ? 2 : 2) ||
|
|
456
|
+
(frameCountCode === 3 && ((new Uint8Array(buffer)[1] ?? 0) & 0x3f) === 0)
|
|
457
|
+
)
|
|
458
|
+
throw new Error("RAW Opus audio has an invalid packet framing header.");
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
271
461
|
const bytesPerFrame = specification.channels * Math.ceil(specification.bitDepth / 8);
|
|
272
462
|
if (bytesPerFrame <= 0 || buffer.byteLength % bytesPerFrame !== 0) {
|
|
273
463
|
throw new Error(
|
|
@@ -276,6 +466,20 @@ function validateRawAudioBuffer(buffer: ArrayBuffer, specification: AudioSpecifi
|
|
|
276
466
|
}
|
|
277
467
|
}
|
|
278
468
|
|
|
469
|
+
function validateContainerFormat(specification: AudioSpecification, format: string): void {
|
|
470
|
+
const expected = formatAudioSpecification(format);
|
|
471
|
+
if (
|
|
472
|
+
(expected.sampleRate > 0 && specification.sampleRate !== expected.sampleRate) ||
|
|
473
|
+
(expected.channels > 0 && specification.channels !== expected.channels) ||
|
|
474
|
+
(expected.codec !== "unknown" && specification.codec !== expected.codec)
|
|
475
|
+
) {
|
|
476
|
+
throw new AudioFormatMismatchError(`Audio container does not match the requested format "${format}".`, [
|
|
477
|
+
expected,
|
|
478
|
+
specification,
|
|
479
|
+
]);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
279
483
|
function validateAudioSpecifications(specs: readonly AudioSpecification[]): void {
|
|
280
484
|
const first = specs[0];
|
|
281
485
|
if (!first) return;
|
|
@@ -378,6 +582,14 @@ function isMp3Format(format: string): boolean {
|
|
|
378
582
|
return /(?:mp3|mpeg)/i.test(format);
|
|
379
583
|
}
|
|
380
584
|
|
|
585
|
+
function isOggFormat(format: string): boolean {
|
|
586
|
+
return /ogg/i.test(format);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function isWebmFormat(format: string): boolean {
|
|
590
|
+
return /webm/i.test(format);
|
|
591
|
+
}
|
|
592
|
+
|
|
381
593
|
function isWavFormat(format: string): boolean {
|
|
382
594
|
return /(?:wav|wave|riff)/i.test(format);
|
|
383
595
|
}
|
|
@@ -392,11 +604,18 @@ function validateMergedAudioBuffer(
|
|
|
392
604
|
buffers: readonly ArrayBuffer[],
|
|
393
605
|
inputSpecs: readonly AudioSpecification[],
|
|
394
606
|
outputMimeType: string,
|
|
607
|
+
allowExternalContainer: boolean,
|
|
395
608
|
): AudioSpecification {
|
|
396
609
|
if (!(merged instanceof ArrayBuffer) || merged.byteLength === 0) {
|
|
397
610
|
throw new MergeError("The custom audio merger returned an empty or invalid audio buffer.");
|
|
398
611
|
}
|
|
399
|
-
|
|
612
|
+
let specification: AudioSpecification;
|
|
613
|
+
try {
|
|
614
|
+
specification = inspectAudioSpecification(merged, format);
|
|
615
|
+
} catch (error) {
|
|
616
|
+
if (!allowExternalContainer) throw error;
|
|
617
|
+
specification = inputSpecs[0] ?? formatAudioSpecification(format);
|
|
618
|
+
}
|
|
400
619
|
if (!outputMimeType.trim()) throw new MergeError("The custom audio merger output MIME type cannot be empty.");
|
|
401
620
|
const firstInput = inputSpecs[0];
|
|
402
621
|
if (
|
|
@@ -423,6 +642,26 @@ function validateMergedAudioBuffer(
|
|
|
423
642
|
return specification;
|
|
424
643
|
}
|
|
425
644
|
|
|
645
|
+
async function withinDeadline<T>(value: Promise<T> | T, deadline: DeadlineController | undefined): Promise<T> {
|
|
646
|
+
if (!deadline) return value;
|
|
647
|
+
deadline.throwIfExpired();
|
|
648
|
+
if (!Number.isFinite(deadline.remainingMs)) return value;
|
|
649
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
650
|
+
try {
|
|
651
|
+
return await Promise.race([
|
|
652
|
+
Promise.resolve(value),
|
|
653
|
+
new Promise<T>((_resolve, reject) => {
|
|
654
|
+
timer = setTimeout(
|
|
655
|
+
() => reject(new SynthesisTimeoutError("Speech synthesis exceeded the total job deadline.")),
|
|
656
|
+
deadline.remainingMs,
|
|
657
|
+
);
|
|
658
|
+
}),
|
|
659
|
+
]);
|
|
660
|
+
} finally {
|
|
661
|
+
if (timer) clearTimeout(timer);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
426
665
|
/** Returns whether the named output format can be safely concatenated without re-multiplexing. */
|
|
427
666
|
export function resolveMergeAudioFormat(format: string): MergeAudioFormat | undefined {
|
|
428
667
|
if (isWavFormat(format)) return "wav";
|
|
@@ -440,6 +679,7 @@ export function mergeAudioBuffers(buffers: readonly ArrayBuffer[], options: Merg
|
|
|
440
679
|
export function mergeAudioBuffers(buffers: readonly ArrayBuffer[], options: MergeAudioOptions | string): ArrayBuffer {
|
|
441
680
|
const format = typeof options === "string" ? options : options?.format;
|
|
442
681
|
if (!format) throw new UnsupportedMergeFormatError("");
|
|
682
|
+
if (!canMergeAudioFormat(format)) throw new UnsupportedMergeFormatError(format);
|
|
443
683
|
try {
|
|
444
684
|
validateAudioSpecifications(buffers.map((buffer) => inspectAudioSpecification(buffer, format)));
|
|
445
685
|
if (isWavFormat(format)) return mergeWavBuffers(buffers);
|
|
@@ -809,10 +1049,25 @@ async function synthesizeWithRetry(
|
|
|
809
1049
|
|
|
810
1050
|
/** Synthesizes one SSML document, optionally retrying transient failures within the job deadline. */
|
|
811
1051
|
export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<SsmlSynthesisResult> {
|
|
812
|
-
const
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
1052
|
+
const deadline = new DeadlineController(config.timeouts?.totalJobMs, config.signal);
|
|
1053
|
+
try {
|
|
1054
|
+
deadline.throwIfExpired();
|
|
1055
|
+
const synthesisConfig: TtsConfig = {
|
|
1056
|
+
...config,
|
|
1057
|
+
signal: deadline.signal,
|
|
1058
|
+
timeouts: config.timeouts ? { ...config.timeouts, totalJobMs: undefined } : undefined,
|
|
1059
|
+
};
|
|
1060
|
+
const result = config.retryOptions
|
|
1061
|
+
? await synthesizeWithRetry(ssml, synthesisConfig, config.retryOptions, () => undefined, deadline.deadlineAtMs)
|
|
1062
|
+
: await synthesizeSsmlOnce(ssml, synthesisConfig);
|
|
1063
|
+
deadline.throwIfExpired();
|
|
1064
|
+
return result;
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
if (deadline.timedOut) throw new SynthesisTimeoutError("Speech synthesis exceeded the total job deadline.");
|
|
1067
|
+
throw error;
|
|
1068
|
+
} finally {
|
|
1069
|
+
deadline.dispose();
|
|
1070
|
+
}
|
|
816
1071
|
}
|
|
817
1072
|
|
|
818
1073
|
interface AbortScope {
|
|
@@ -873,7 +1128,12 @@ export async function synthesizeSsmlChunks(
|
|
|
873
1128
|
const totalChunks = chunks.length;
|
|
874
1129
|
const inputs = chunks.map((chunk) => (typeof chunk === "string" ? { ssml: chunk } : chunk));
|
|
875
1130
|
const fingerprints = inputs.map((chunk) =>
|
|
876
|
-
computeChunkFingerprint(chunk.ssml, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT
|
|
1131
|
+
computeChunkFingerprint(chunk.ssml, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT, {
|
|
1132
|
+
region: config.region,
|
|
1133
|
+
endpoint: config.endpoint,
|
|
1134
|
+
customHeaders: config.customHeaders,
|
|
1135
|
+
fingerprintSchemaVersion: config.fingerprintSchemaVersion,
|
|
1136
|
+
}),
|
|
877
1137
|
);
|
|
878
1138
|
const results: Array<SsmlSynthesisResult | undefined> = new Array(totalChunks);
|
|
879
1139
|
const cachedChunks = new Map((config.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
|
|
@@ -904,6 +1164,7 @@ export async function synthesizeSsmlChunks(
|
|
|
904
1164
|
config.timeouts?.totalJobMs !== undefined && config.timeouts.totalJobMs > 0
|
|
905
1165
|
? jobStartedAt + config.timeouts.totalJobMs
|
|
906
1166
|
: undefined;
|
|
1167
|
+
const deadline = new DeadlineController(config.timeouts?.totalJobMs, config.signal);
|
|
907
1168
|
const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
|
|
908
1169
|
const report = (event: SynthesisProgressEvent): void => config.onProgress?.(event);
|
|
909
1170
|
for (const [index, input] of inputs.entries()) {
|
|
@@ -944,7 +1205,7 @@ export async function synthesizeSsmlChunks(
|
|
|
944
1205
|
input.ssml,
|
|
945
1206
|
{
|
|
946
1207
|
...config,
|
|
947
|
-
signal:
|
|
1208
|
+
signal: deadline.signal,
|
|
948
1209
|
...(input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {}),
|
|
949
1210
|
...((input.sourceNodePath ?? config.sourceNodePath)
|
|
950
1211
|
? { sourceNodePath: [...(input.sourceNodePath ?? config.sourceNodePath ?? [])] }
|
|
@@ -994,7 +1255,7 @@ export async function synthesizeSsmlChunks(
|
|
|
994
1255
|
status: wasCancelled ? "cancelled" : "failed",
|
|
995
1256
|
isOriginalFailure: !wasCancelled,
|
|
996
1257
|
canResume: true,
|
|
997
|
-
error: error
|
|
1258
|
+
error: serializeChunkError(error, "synthesis", !wasCancelled),
|
|
998
1259
|
};
|
|
999
1260
|
report({
|
|
1000
1261
|
currentChunk: completed,
|
|
@@ -1014,13 +1275,18 @@ export async function synthesizeSsmlChunks(
|
|
|
1014
1275
|
try {
|
|
1015
1276
|
await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
|
|
1016
1277
|
if (firstError) throw firstError;
|
|
1278
|
+
const missingChunkIndices = Array.from({ length: totalChunks }, (_value, index) =>
|
|
1279
|
+
results[index] === undefined ? index : undefined,
|
|
1280
|
+
).filter((index): index is number => index !== undefined);
|
|
1281
|
+
if (missingChunkIndices.length > 0) throw new IncompleteChunkSetError(totalChunks, missingChunkIndices);
|
|
1017
1282
|
const orderedResults = results.filter((result): result is SsmlSynthesisResult => result !== undefined);
|
|
1018
1283
|
return await mergeSynthesisResults(orderedResults, {
|
|
1019
1284
|
format: (config.outputFormat ?? DEFAULT_OUTPUT_FORMAT) as AzureTtsOutputFormat,
|
|
1020
|
-
signal:
|
|
1285
|
+
signal: deadline.signal,
|
|
1021
1286
|
customMerger: config.customMerger,
|
|
1022
1287
|
outputMimeType: config.outputMimeType,
|
|
1023
1288
|
postMergeValidator: config.postMergeValidator,
|
|
1289
|
+
deadline,
|
|
1024
1290
|
});
|
|
1025
1291
|
} catch (error) {
|
|
1026
1292
|
if (firstError && config.cancelOnFailure !== false) {
|
|
@@ -1052,6 +1318,7 @@ export async function synthesizeSsmlChunks(
|
|
|
1052
1318
|
throw error;
|
|
1053
1319
|
} finally {
|
|
1054
1320
|
scope.dispose();
|
|
1321
|
+
deadline.dispose();
|
|
1055
1322
|
}
|
|
1056
1323
|
}
|
|
1057
1324
|
|
|
@@ -1159,21 +1426,34 @@ export function mergeSynthesisResults(
|
|
|
1159
1426
|
const format = resolvedOptions?.format;
|
|
1160
1427
|
if (!format) throw new UnsupportedMergeFormatError("");
|
|
1161
1428
|
const buffers = results.map((result) => result.audioData);
|
|
1162
|
-
const inputSpecs = results.map((result) =>
|
|
1429
|
+
const inputSpecs = results.map((result) => {
|
|
1430
|
+
if (result.audioSpec) return result.audioSpec;
|
|
1431
|
+
try {
|
|
1432
|
+
return inspectAudioSpecification(result.audioData, format);
|
|
1433
|
+
} catch (error) {
|
|
1434
|
+
if (resolvedOptions.customMerger) return formatAudioSpecification(format);
|
|
1435
|
+
throw error;
|
|
1436
|
+
}
|
|
1437
|
+
});
|
|
1163
1438
|
validateAudioSpecifications(inputSpecs);
|
|
1164
|
-
const
|
|
1439
|
+
const deadline = resolvedOptions.deadline;
|
|
1440
|
+
deadline?.throwIfExpired();
|
|
1441
|
+
const signal = resolvedOptions.signal ?? deadline?.signal ?? new AbortController().signal;
|
|
1165
1442
|
if (signal.aborted) throw new SynthesisCancelledError();
|
|
1166
1443
|
if (resolvedOptions.customMerger) {
|
|
1167
|
-
return
|
|
1168
|
-
.then(() =>
|
|
1444
|
+
return withinDeadline(
|
|
1445
|
+
Promise.resolve().then(() =>
|
|
1169
1446
|
resolvedOptions.customMerger?.(buffers, {
|
|
1170
1447
|
format,
|
|
1171
1448
|
outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
|
|
1172
1449
|
inputSpecs,
|
|
1173
1450
|
signal,
|
|
1174
1451
|
}),
|
|
1175
|
-
)
|
|
1452
|
+
),
|
|
1453
|
+
deadline,
|
|
1454
|
+
)
|
|
1176
1455
|
.then((merged) => {
|
|
1456
|
+
deadline?.throwIfExpired();
|
|
1177
1457
|
if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
|
|
1178
1458
|
if (signal.aborted) throw new SynthesisCancelledError();
|
|
1179
1459
|
const mergedSpec = validateMergedAudioBuffer(
|
|
@@ -1182,6 +1462,7 @@ export function mergeSynthesisResults(
|
|
|
1182
1462
|
buffers,
|
|
1183
1463
|
inputSpecs,
|
|
1184
1464
|
resolvedOptions.outputMimeType ?? resolveMimeType(format),
|
|
1465
|
+
true,
|
|
1185
1466
|
);
|
|
1186
1467
|
const result = createMergedResult(results, merged, format, mergedSpec, resolvedOptions.outputMimeType);
|
|
1187
1468
|
return Promise.resolve(
|
|
@@ -1192,6 +1473,7 @@ export function mergeSynthesisResults(
|
|
|
1192
1473
|
signal,
|
|
1193
1474
|
}),
|
|
1194
1475
|
).then((valid) => {
|
|
1476
|
+
deadline?.throwIfExpired();
|
|
1195
1477
|
if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
|
|
1196
1478
|
return result;
|
|
1197
1479
|
});
|
|
@@ -1203,6 +1485,7 @@ export function mergeSynthesisResults(
|
|
|
1203
1485
|
});
|
|
1204
1486
|
}
|
|
1205
1487
|
try {
|
|
1488
|
+
deadline?.throwIfExpired();
|
|
1206
1489
|
const result = createMergedResult(
|
|
1207
1490
|
results,
|
|
1208
1491
|
mergeAudioBuffers(buffers, { format }),
|
|
@@ -1218,12 +1501,14 @@ export function mergeSynthesisResults(
|
|
|
1218
1501
|
signal,
|
|
1219
1502
|
});
|
|
1220
1503
|
if (validation instanceof Promise)
|
|
1221
|
-
return validation.then((valid) => {
|
|
1504
|
+
return withinDeadline(validation, deadline).then((valid) => {
|
|
1505
|
+
deadline?.throwIfExpired();
|
|
1222
1506
|
if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
|
|
1223
1507
|
return result;
|
|
1224
1508
|
});
|
|
1225
1509
|
if (validation === false) throw new MergeError("The post-merge validator rejected the merged audio.");
|
|
1226
1510
|
}
|
|
1511
|
+
deadline?.throwIfExpired();
|
|
1227
1512
|
return result;
|
|
1228
1513
|
} catch (error) {
|
|
1229
1514
|
if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
|
package/src/types.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { SsmlSourceMarker, SsmlSourceTextSegment, SsmlTextRange } from "@ssml-builder-js/ssml-core";
|
|
2
2
|
import type { AzureTtsOutputFormat } from "./outputFormats.ts";
|
|
3
|
-
import type {
|
|
4
|
-
import type { SsmlValidationError } from "./safe.ts";
|
|
3
|
+
import type { SerializedChunkError } from "./errors.ts";
|
|
5
4
|
|
|
6
5
|
export interface TtsConfig {
|
|
7
6
|
signal?: AbortSignal;
|
|
@@ -11,6 +10,8 @@ export interface TtsConfig {
|
|
|
11
10
|
subscriptionKey: string;
|
|
12
11
|
region: string;
|
|
13
12
|
outputFormat?: string;
|
|
13
|
+
customHeaders?: Readonly<Record<string, string>>;
|
|
14
|
+
fingerprintSchemaVersion?: string;
|
|
14
15
|
/** Original plain-text range represented by this synthesis request. */
|
|
15
16
|
sourceTextRange?: { start: number; end: number };
|
|
16
17
|
/** Reports chunk lifecycle events when using chunk synthesis. */
|
|
@@ -141,6 +142,8 @@ export interface SsmlSynthesisChunk {
|
|
|
141
142
|
export interface SynthesizeChunksOptions {
|
|
142
143
|
onProgress?: (event: SynthesisProgressEvent) => void;
|
|
143
144
|
outputFormat?: AzureTtsOutputFormat | string;
|
|
145
|
+
customHeaders?: Readonly<Record<string, string>>;
|
|
146
|
+
fingerprintSchemaVersion?: string;
|
|
144
147
|
signal?: AbortSignal;
|
|
145
148
|
timeoutMs?: number;
|
|
146
149
|
timeouts?: SynthesisTimeouts;
|
|
@@ -184,7 +187,7 @@ export type ChunkExecutionStatus = "succeeded" | "failed" | "cancelled" | "pendi
|
|
|
184
187
|
export interface ChunkExecutionState {
|
|
185
188
|
chunkIndex: number;
|
|
186
189
|
status: ChunkExecutionStatus;
|
|
187
|
-
error?:
|
|
190
|
+
error?: SerializedChunkError;
|
|
188
191
|
isOriginalFailure?: boolean;
|
|
189
192
|
canResume: boolean;
|
|
190
193
|
result?: SsmlSynthesisResult;
|
|
@@ -233,6 +236,8 @@ export interface AzureTtsClientOptions {
|
|
|
233
236
|
region: string;
|
|
234
237
|
endpoint?: string;
|
|
235
238
|
outputFormat?: string;
|
|
239
|
+
customHeaders?: Readonly<Record<string, string>>;
|
|
240
|
+
fingerprintSchemaVersion?: string;
|
|
236
241
|
logger?: AzureTtsLogger;
|
|
237
242
|
onProgress?: (event: SynthesisProgressEvent) => void;
|
|
238
243
|
concurrency?: number;
|
package/test/synthesis.test.ts
CHANGED
|
@@ -181,6 +181,26 @@ test("synthesizeSpeech aborts the SDK request on timeout and settles its promise
|
|
|
181
181
|
assert.equal(closeCount, 1);
|
|
182
182
|
});
|
|
183
183
|
|
|
184
|
+
test("synthesizeSsml enforces totalJobMs across the SDK request", async (t) => {
|
|
185
|
+
let closeCount = 0;
|
|
186
|
+
installHangingSynthesisMock(
|
|
187
|
+
t,
|
|
188
|
+
() => undefined,
|
|
189
|
+
() => (closeCount += 1),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
await assert.rejects(
|
|
193
|
+
synthesizeSsml("<speak>Hello</speak>", {
|
|
194
|
+
endpoint,
|
|
195
|
+
subscriptionKey,
|
|
196
|
+
region,
|
|
197
|
+
timeouts: { totalJobMs: 10 },
|
|
198
|
+
}),
|
|
199
|
+
/exceeded the total job deadline/,
|
|
200
|
+
);
|
|
201
|
+
assert.equal(closeCount, 1);
|
|
202
|
+
});
|
|
203
|
+
|
|
184
204
|
const azureKey = process.env.AZURE_SPEECH_KEY;
|
|
185
205
|
const azureRegion = process.env.AZURE_SPEECH_REGION;
|
|
186
206
|
|