@ssml-builder-js/azure-tts-client 2.17.0 → 2.19.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 +20 -0
- package/dist/index.d.mts +123 -43
- package/dist/index.d.ts +123 -43
- package/dist/index.js +674 -90
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +670 -90
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +64 -8
- package/src/deadline.ts +52 -0
- package/src/errors.ts +86 -4
- package/src/index.ts +15 -1
- package/src/outputFormats.ts +3 -0
- package/src/safe.ts +115 -25
- package/src/synthesis.ts +508 -50
- package/src/types.ts +32 -1
- package/test/v218-pipeline.test.ts +91 -0
- package/test/v219-pipeline.test.ts +131 -0
package/src/synthesis.ts
CHANGED
|
@@ -22,7 +22,10 @@ import type {
|
|
|
22
22
|
RetryOptions,
|
|
23
23
|
CustomAudioMerger,
|
|
24
24
|
PostMergeValidator,
|
|
25
|
+
ChunkExecutionState,
|
|
25
26
|
} from "./types.ts";
|
|
27
|
+
import { DeadlineController } from "./deadline.ts";
|
|
28
|
+
import { IncompleteChunkSetError, serializeChunkError } from "./errors.ts";
|
|
26
29
|
|
|
27
30
|
export type MergeAudioFormat = "wav" | "mp3" | "raw";
|
|
28
31
|
|
|
@@ -34,9 +37,57 @@ export interface MergeAudioOptions {
|
|
|
34
37
|
|
|
35
38
|
export type InputAudioSpecs = AudioSpecification[];
|
|
36
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Creates a deterministic, runtime-independent fingerprint for a synthesis chunk.
|
|
42
|
+
* The complete SSML is included so changes to voice, language, prosody, or text
|
|
43
|
+
* invalidate a cached result even when those settings are nested in the markup.
|
|
44
|
+
*/
|
|
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 {
|
|
61
|
+
const readAttribute = (name: string): string => {
|
|
62
|
+
const pattern = new RegExp(`(?:${name})\\s*=\\s*[\\"']([^\\"']*)`, "gi");
|
|
63
|
+
return [...ssml.matchAll(pattern)].map((match) => match[1] ?? "").join("|");
|
|
64
|
+
};
|
|
65
|
+
const headers = Object.fromEntries(
|
|
66
|
+
Object.entries(options.customHeaders ?? {}).sort(([first], [second]) => first.localeCompare(second)),
|
|
67
|
+
);
|
|
68
|
+
const payload = JSON.stringify({
|
|
69
|
+
ssml,
|
|
70
|
+
outputFormat,
|
|
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",
|
|
77
|
+
});
|
|
78
|
+
let hash = 0xcbf29ce484222325n;
|
|
79
|
+
const mask = 0xffffffffffffffffn;
|
|
80
|
+
for (let index = 0; index < payload.length; index += 1) {
|
|
81
|
+
hash ^= BigInt(payload.charCodeAt(index));
|
|
82
|
+
hash = (hash * 0x100000001b3n) & mask;
|
|
83
|
+
}
|
|
84
|
+
return `fnv1a64-${hash.toString(16).padStart(16, "0")}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
37
87
|
export interface MergeSynthesisOptions extends MergeAudioOptions {
|
|
38
88
|
customMerger?: CustomAudioMerger;
|
|
39
89
|
postMergeValidator?: PostMergeValidator;
|
|
90
|
+
deadline?: DeadlineController;
|
|
40
91
|
}
|
|
41
92
|
|
|
42
93
|
type AsyncMergeSynthesisOptions = MergeSynthesisOptions & {
|
|
@@ -96,9 +147,11 @@ function parseWav(buffer: ArrayBuffer): ParsedWav {
|
|
|
96
147
|
return { chunks, data, format };
|
|
97
148
|
}
|
|
98
149
|
|
|
99
|
-
function
|
|
100
|
-
const match =
|
|
101
|
-
|
|
150
|
+
function formatSampleRate(format: string): number {
|
|
151
|
+
const match = /(?:^|-)(\d+)(khz|hz)(?:-|$)/i.exec(format);
|
|
152
|
+
if (!match?.[1] || !match[2]) return 0;
|
|
153
|
+
const value = Number(match[1]);
|
|
154
|
+
return match[2].toLowerCase() === "khz" ? value * 1000 : value;
|
|
102
155
|
}
|
|
103
156
|
|
|
104
157
|
function formatChannels(format: string, fallback: number): number {
|
|
@@ -108,7 +161,7 @@ function formatChannels(format: string, fallback: number): number {
|
|
|
108
161
|
}
|
|
109
162
|
|
|
110
163
|
function formatAudioSpecification(format: string): AudioSpecification {
|
|
111
|
-
const sampleRate =
|
|
164
|
+
const sampleRate = formatSampleRate(format);
|
|
112
165
|
const channels = formatChannels(format, 0);
|
|
113
166
|
const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
|
|
114
167
|
const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1000 : undefined;
|
|
@@ -118,9 +171,15 @@ function formatAudioSpecification(format: string): AudioSpecification {
|
|
|
118
171
|
? "opus"
|
|
119
172
|
: /silk/i.test(format)
|
|
120
173
|
? "silk"
|
|
121
|
-
: /
|
|
122
|
-
? "
|
|
123
|
-
:
|
|
174
|
+
: /mulaw|mu-law/i.test(format)
|
|
175
|
+
? "mulaw"
|
|
176
|
+
: /alaw|a-law/i.test(format)
|
|
177
|
+
? "alaw"
|
|
178
|
+
: /siren/i.test(format)
|
|
179
|
+
? "siren"
|
|
180
|
+
: /pcm/i.test(format)
|
|
181
|
+
? "pcm"
|
|
182
|
+
: "unknown";
|
|
124
183
|
const bitDepthMatch = /(\d+)bit/i.exec(format);
|
|
125
184
|
const container = /(?:wav|wave|riff)/i.test(format)
|
|
126
185
|
? "riff-wave"
|
|
@@ -143,7 +202,7 @@ function formatAudioSpecification(format: string): AudioSpecification {
|
|
|
143
202
|
...(bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {}),
|
|
144
203
|
...(container ? { container } : {}),
|
|
145
204
|
isVbr: /vbr/i.test(format),
|
|
146
|
-
isCompressed: codec
|
|
205
|
+
isCompressed: codec !== "pcm" && codec !== "unknown",
|
|
147
206
|
};
|
|
148
207
|
}
|
|
149
208
|
|
|
@@ -187,6 +246,143 @@ function parseMp3Specification(buffer: ArrayBuffer, format: string): AudioSpecif
|
|
|
187
246
|
return undefined;
|
|
188
247
|
}
|
|
189
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
|
+
|
|
190
386
|
/** Extracts the stream specification from a WAV/MP3 header and output-format fallback. */
|
|
191
387
|
export function inspectAudioSpecification(buffer: ArrayBuffer, format: string): AudioSpecification {
|
|
192
388
|
if (isWavFormat(format) || ascii(new Uint8Array(buffer), 0, "RIFF")) {
|
|
@@ -197,21 +393,91 @@ export function inspectAudioSpecification(buffer: ArrayBuffer, format: string):
|
|
|
197
393
|
const channels = view.getUint16(2, true);
|
|
198
394
|
const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
|
|
199
395
|
const formatCode = view.getUint16(0, true);
|
|
396
|
+
const namedCodec = formatAudioSpecification(format).codec;
|
|
397
|
+
const codec: AudioSpecification["codec"] =
|
|
398
|
+
formatCode === 1
|
|
399
|
+
? "pcm"
|
|
400
|
+
: formatCode === 6
|
|
401
|
+
? "alaw"
|
|
402
|
+
: formatCode === 7
|
|
403
|
+
? "mulaw"
|
|
404
|
+
: namedCodec === "siren"
|
|
405
|
+
? "siren"
|
|
406
|
+
: "unknown";
|
|
200
407
|
return {
|
|
201
408
|
format,
|
|
202
409
|
mimeType: "audio/wav",
|
|
203
|
-
codec
|
|
410
|
+
codec,
|
|
204
411
|
sampleRate,
|
|
205
412
|
channels,
|
|
206
413
|
...(sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {}),
|
|
207
414
|
bitDepth: bitsPerSample,
|
|
208
415
|
container: "riff-wave",
|
|
209
416
|
isVbr: false,
|
|
210
|
-
isCompressed:
|
|
417
|
+
isCompressed: codec !== "pcm" && codec !== "unknown",
|
|
211
418
|
};
|
|
212
419
|
}
|
|
213
|
-
if (isMp3Format(format))
|
|
214
|
-
|
|
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
|
+
}
|
|
434
|
+
const specification = formatAudioSpecification(format);
|
|
435
|
+
if (specification.container === "raw") validateRawAudioBuffer(buffer, specification);
|
|
436
|
+
return specification;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function validateRawAudioBuffer(buffer: ArrayBuffer, specification: AudioSpecification): void {
|
|
440
|
+
if (specification.sampleRate <= 0 || specification.channels <= 0 || specification.bitDepth === undefined) {
|
|
441
|
+
throw new Error(`RAW audio format "${specification.format}" does not define a complete audio specification.`);
|
|
442
|
+
}
|
|
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
|
+
}
|
|
461
|
+
const bytesPerFrame = specification.channels * Math.ceil(specification.bitDepth / 8);
|
|
462
|
+
if (bytesPerFrame <= 0 || buffer.byteLength % bytesPerFrame !== 0) {
|
|
463
|
+
throw new Error(
|
|
464
|
+
`RAW audio buffer size ${buffer.byteLength} is not aligned to ${bytesPerFrame}-byte audio frames for "${specification.format}".`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
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
|
+
}
|
|
215
481
|
}
|
|
216
482
|
|
|
217
483
|
function validateAudioSpecifications(specs: readonly AudioSpecification[]): void {
|
|
@@ -221,6 +487,7 @@ function validateAudioSpecifications(specs: readonly AudioSpecification[]): void
|
|
|
221
487
|
(spec) =>
|
|
222
488
|
spec.sampleRate !== first.sampleRate ||
|
|
223
489
|
spec.channels !== first.channels ||
|
|
490
|
+
spec.codec !== first.codec ||
|
|
224
491
|
(first.bitrate !== undefined && spec.bitrate !== undefined && spec.bitrate !== first.bitrate) ||
|
|
225
492
|
(first.bitDepth !== undefined && spec.bitDepth !== undefined && spec.bitDepth !== first.bitDepth) ||
|
|
226
493
|
(first.container !== undefined && spec.container !== undefined && spec.container !== first.container) ||
|
|
@@ -315,6 +582,14 @@ function isMp3Format(format: string): boolean {
|
|
|
315
582
|
return /(?:mp3|mpeg)/i.test(format);
|
|
316
583
|
}
|
|
317
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
|
+
|
|
318
593
|
function isWavFormat(format: string): boolean {
|
|
319
594
|
return /(?:wav|wave|riff)/i.test(format);
|
|
320
595
|
}
|
|
@@ -323,6 +598,70 @@ function isRawFormat(format: string): boolean {
|
|
|
323
598
|
return /^raw(?:-|$)/i.test(format);
|
|
324
599
|
}
|
|
325
600
|
|
|
601
|
+
function validateMergedAudioBuffer(
|
|
602
|
+
merged: ArrayBuffer,
|
|
603
|
+
format: string,
|
|
604
|
+
buffers: readonly ArrayBuffer[],
|
|
605
|
+
inputSpecs: readonly AudioSpecification[],
|
|
606
|
+
outputMimeType: string,
|
|
607
|
+
allowExternalContainer: boolean,
|
|
608
|
+
): AudioSpecification {
|
|
609
|
+
if (!(merged instanceof ArrayBuffer) || merged.byteLength === 0) {
|
|
610
|
+
throw new MergeError("The custom audio merger returned an empty or invalid audio buffer.");
|
|
611
|
+
}
|
|
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
|
+
}
|
|
619
|
+
if (!outputMimeType.trim()) throw new MergeError("The custom audio merger output MIME type cannot be empty.");
|
|
620
|
+
const firstInput = inputSpecs[0];
|
|
621
|
+
if (
|
|
622
|
+
firstInput &&
|
|
623
|
+
(specification.sampleRate !== firstInput.sampleRate ||
|
|
624
|
+
specification.channels !== firstInput.channels ||
|
|
625
|
+
specification.codec !== firstInput.codec ||
|
|
626
|
+
(firstInput.bitDepth !== undefined && specification.bitDepth !== firstInput.bitDepth))
|
|
627
|
+
) {
|
|
628
|
+
throw new AudioFormatMismatchError("The custom audio merger returned an incompatible audio stream.", [
|
|
629
|
+
...inputSpecs,
|
|
630
|
+
specification,
|
|
631
|
+
]);
|
|
632
|
+
}
|
|
633
|
+
if (isRawFormat(format)) {
|
|
634
|
+
const expectedSize = buffers.reduce((total, input) => total + input.byteLength, 0);
|
|
635
|
+
if (merged.byteLength !== expectedSize) {
|
|
636
|
+
throw new MergeError(
|
|
637
|
+
`The custom audio merger returned ${merged.byteLength} bytes; ${expectedSize} were expected.`,
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
validateRawAudioBuffer(merged, specification);
|
|
641
|
+
}
|
|
642
|
+
return specification;
|
|
643
|
+
}
|
|
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
|
+
|
|
326
665
|
/** Returns whether the named output format can be safely concatenated without re-multiplexing. */
|
|
327
666
|
export function resolveMergeAudioFormat(format: string): MergeAudioFormat | undefined {
|
|
328
667
|
if (isWavFormat(format)) return "wav";
|
|
@@ -340,6 +679,7 @@ export function mergeAudioBuffers(buffers: readonly ArrayBuffer[], options: Merg
|
|
|
340
679
|
export function mergeAudioBuffers(buffers: readonly ArrayBuffer[], options: MergeAudioOptions | string): ArrayBuffer {
|
|
341
680
|
const format = typeof options === "string" ? options : options?.format;
|
|
342
681
|
if (!format) throw new UnsupportedMergeFormatError("");
|
|
682
|
+
if (!canMergeAudioFormat(format)) throw new UnsupportedMergeFormatError(format);
|
|
343
683
|
try {
|
|
344
684
|
validateAudioSpecifications(buffers.map((buffer) => inspectAudioSpecification(buffer, format)));
|
|
345
685
|
if (isWavFormat(format)) return mergeWavBuffers(buffers);
|
|
@@ -382,7 +722,7 @@ function closeSpeechResources(speechConfig: SpeechSDK.SpeechConfig, synthesizer:
|
|
|
382
722
|
|
|
383
723
|
const ticksToMilliseconds = (ticks: number): number => Math.max(0, ticks) / 10_000;
|
|
384
724
|
|
|
385
|
-
|
|
725
|
+
async function synthesizeSsmlOnce(ssml: string, config: TtsConfig): Promise<SsmlSynthesisResult> {
|
|
386
726
|
if (config.signal?.aborted) {
|
|
387
727
|
throw new SynthesisCancelledError();
|
|
388
728
|
}
|
|
@@ -540,6 +880,13 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
540
880
|
rejectWithError(err);
|
|
541
881
|
return;
|
|
542
882
|
}
|
|
883
|
+
let audioSpec: AudioSpecification;
|
|
884
|
+
try {
|
|
885
|
+
audioSpec = inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT);
|
|
886
|
+
} catch (error) {
|
|
887
|
+
rejectWithError(error);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
543
890
|
settled = true;
|
|
544
891
|
cleanup();
|
|
545
892
|
closeResources();
|
|
@@ -579,8 +926,8 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
579
926
|
resolve({
|
|
580
927
|
audioData: result.audioData,
|
|
581
928
|
durationMs,
|
|
582
|
-
audioSpec
|
|
583
|
-
mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
|
|
929
|
+
audioSpec,
|
|
930
|
+
mimeType: config.outputMimeType ?? resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
|
|
584
931
|
...(config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {}),
|
|
585
932
|
...(requestId ? { requestId } : {}),
|
|
586
933
|
...(sourceBoundaries.length > 0
|
|
@@ -596,7 +943,7 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
596
943
|
abortHandler = () => rejectWithError(new SynthesisCancelledError());
|
|
597
944
|
config.signal.addEventListener("abort", abortHandler, { once: true });
|
|
598
945
|
}
|
|
599
|
-
const timeoutMs = config.timeouts?.perChunkMs ?? config.timeoutMs;
|
|
946
|
+
const timeoutMs = config.timeouts?.perChunkMs ?? config.timeouts?.totalJobMs ?? config.timeoutMs;
|
|
600
947
|
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
601
948
|
timeout = setTimeout(
|
|
602
949
|
() => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
|
|
@@ -659,6 +1006,7 @@ async function synthesizeWithRetry(
|
|
|
659
1006
|
config: TtsConfig,
|
|
660
1007
|
retryOptions: RetryOptions | undefined,
|
|
661
1008
|
onRetry: (retryAttempt: number, nextRetryDelayMs: number) => void,
|
|
1009
|
+
deadlineAtMs?: number,
|
|
662
1010
|
): Promise<SsmlSynthesisResult> {
|
|
663
1011
|
const options = retryOptions
|
|
664
1012
|
? {
|
|
@@ -672,7 +1020,7 @@ async function synthesizeWithRetry(
|
|
|
672
1020
|
while (true) {
|
|
673
1021
|
if (config.signal?.aborted) throw new SynthesisCancelledError();
|
|
674
1022
|
try {
|
|
675
|
-
return await
|
|
1023
|
+
return await synthesizeSsmlOnce(ssml, config);
|
|
676
1024
|
} catch (error) {
|
|
677
1025
|
if (
|
|
678
1026
|
!options ||
|
|
@@ -682,12 +1030,46 @@ async function synthesizeWithRetry(
|
|
|
682
1030
|
throw error;
|
|
683
1031
|
attempt += 1;
|
|
684
1032
|
const delayMs = retryDelay(options, attempt, error);
|
|
1033
|
+
const remainingMs = deadlineAtMs === undefined ? undefined : Math.max(0, deadlineAtMs - Date.now());
|
|
1034
|
+
if (
|
|
1035
|
+
getRetryAfterDelayMs(error) !== undefined &&
|
|
1036
|
+
(delayMs > options.maxDelayMs || (remainingMs !== undefined && delayMs > remainingMs))
|
|
1037
|
+
) {
|
|
1038
|
+
throw new SynthesisTimeoutError(
|
|
1039
|
+
remainingMs === undefined
|
|
1040
|
+
? `Retry-After exceeded maxDelayMs (${options.maxDelayMs} ms).`
|
|
1041
|
+
: `Retry-After exceeded the remaining total job timeout (${remainingMs} ms).`,
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
685
1044
|
onRetry(attempt, delayMs);
|
|
686
|
-
await waitForRetry(delayMs, config.signal);
|
|
1045
|
+
await waitForRetry(Math.min(delayMs, remainingMs ?? delayMs), config.signal);
|
|
687
1046
|
}
|
|
688
1047
|
}
|
|
689
1048
|
}
|
|
690
1049
|
|
|
1050
|
+
/** Synthesizes one SSML document, optionally retrying transient failures within the job deadline. */
|
|
1051
|
+
export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<SsmlSynthesisResult> {
|
|
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
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
691
1073
|
interface AbortScope {
|
|
692
1074
|
signal: AbortSignal;
|
|
693
1075
|
timedOut: () => boolean;
|
|
@@ -725,10 +1107,11 @@ async function synthesizeChunkWithTimeout(
|
|
|
725
1107
|
retryOptions: RetryOptions | undefined,
|
|
726
1108
|
timeoutMs: number | undefined,
|
|
727
1109
|
onRetry: (retryAttempt: number, nextRetryDelayMs: number) => void,
|
|
1110
|
+
deadlineAtMs?: number,
|
|
728
1111
|
): Promise<SsmlSynthesisResult> {
|
|
729
1112
|
const scope = createAbortScope(config.signal, timeoutMs);
|
|
730
1113
|
try {
|
|
731
|
-
return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry);
|
|
1114
|
+
return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry, deadlineAtMs);
|
|
732
1115
|
} catch (error) {
|
|
733
1116
|
if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
|
|
734
1117
|
throw error;
|
|
@@ -742,21 +1125,49 @@ export async function synthesizeSsmlChunks(
|
|
|
742
1125
|
chunks: readonly (SsmlSynthesisChunk | string)[],
|
|
743
1126
|
config: TtsConfig,
|
|
744
1127
|
): Promise<SsmlSynthesisResult> {
|
|
745
|
-
const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
|
|
746
1128
|
const totalChunks = chunks.length;
|
|
1129
|
+
const inputs = chunks.map((chunk) => (typeof chunk === "string" ? { ssml: chunk } : chunk));
|
|
1130
|
+
const fingerprints = inputs.map((chunk) =>
|
|
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
|
+
}),
|
|
1137
|
+
);
|
|
1138
|
+
const results: Array<SsmlSynthesisResult | undefined> = new Array(totalChunks);
|
|
747
1139
|
const cachedChunks = new Map((config.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
|
|
1140
|
+
const invalidCachedIndices = new Set<number>();
|
|
1141
|
+
const chunkStates: ChunkExecutionState[] = inputs.map((_chunk, chunkIndex) => ({
|
|
1142
|
+
chunkIndex,
|
|
1143
|
+
status: "pending",
|
|
1144
|
+
canResume: true,
|
|
1145
|
+
}));
|
|
748
1146
|
for (const [index, cached] of cachedChunks) {
|
|
749
|
-
if (index
|
|
1147
|
+
if (index < 0 || index >= totalChunks) continue;
|
|
1148
|
+
const isValid = config.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index];
|
|
1149
|
+
if (isValid) {
|
|
1150
|
+
results[index] = { ...cached };
|
|
1151
|
+
chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
|
|
1152
|
+
} else {
|
|
1153
|
+
invalidCachedIndices.add(index);
|
|
1154
|
+
}
|
|
750
1155
|
}
|
|
751
1156
|
const requestedIndices = config.resumeChunkIndices
|
|
752
1157
|
? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks))
|
|
753
1158
|
: undefined;
|
|
754
1159
|
const shouldSynthesize = (index: number): boolean =>
|
|
755
|
-
!cachedChunks.has(index)
|
|
1160
|
+
(!cachedChunks.has(index) || invalidCachedIndices.has(index)) &&
|
|
1161
|
+
(requestedIndices === undefined || requestedIndices.has(index) || invalidCachedIndices.has(index));
|
|
1162
|
+
const jobStartedAt = Date.now();
|
|
1163
|
+
const jobDeadlineAt =
|
|
1164
|
+
config.timeouts?.totalJobMs !== undefined && config.timeouts.totalJobMs > 0
|
|
1165
|
+
? jobStartedAt + config.timeouts.totalJobMs
|
|
1166
|
+
: undefined;
|
|
1167
|
+
const deadline = new DeadlineController(config.timeouts?.totalJobMs, config.signal);
|
|
756
1168
|
const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
|
|
757
1169
|
const report = (event: SynthesisProgressEvent): void => config.onProgress?.(event);
|
|
758
|
-
for (const [index,
|
|
759
|
-
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
1170
|
+
for (const [index, input] of inputs.entries()) {
|
|
760
1171
|
report({
|
|
761
1172
|
currentChunk: index,
|
|
762
1173
|
totalChunks,
|
|
@@ -778,8 +1189,7 @@ export async function synthesizeSsmlChunks(
|
|
|
778
1189
|
if (index >= chunks.length) return;
|
|
779
1190
|
if (!shouldSynthesize(index)) continue;
|
|
780
1191
|
if (firstError && config.cancelOnFailure !== false) return;
|
|
781
|
-
const
|
|
782
|
-
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
1192
|
+
const input = inputs[index];
|
|
783
1193
|
report({
|
|
784
1194
|
currentChunk: completed,
|
|
785
1195
|
totalChunks,
|
|
@@ -795,7 +1205,7 @@ export async function synthesizeSsmlChunks(
|
|
|
795
1205
|
input.ssml,
|
|
796
1206
|
{
|
|
797
1207
|
...config,
|
|
798
|
-
signal:
|
|
1208
|
+
signal: deadline.signal,
|
|
799
1209
|
...(input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {}),
|
|
800
1210
|
...((input.sourceNodePath ?? config.sourceNodePath)
|
|
801
1211
|
? { sourceNodePath: [...(input.sourceNodePath ?? config.sourceNodePath ?? [])] }
|
|
@@ -820,8 +1230,10 @@ export async function synthesizeSsmlChunks(
|
|
|
820
1230
|
nextRetryDelayMs,
|
|
821
1231
|
isRetrying: true,
|
|
822
1232
|
}),
|
|
1233
|
+
jobDeadlineAt,
|
|
823
1234
|
);
|
|
824
1235
|
results[index] = result;
|
|
1236
|
+
chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result };
|
|
825
1237
|
completed += 1;
|
|
826
1238
|
report({
|
|
827
1239
|
currentChunk: completed,
|
|
@@ -833,7 +1245,18 @@ export async function synthesizeSsmlChunks(
|
|
|
833
1245
|
durationMs: Date.now() - startedAt,
|
|
834
1246
|
});
|
|
835
1247
|
} catch (error) {
|
|
836
|
-
|
|
1248
|
+
const wasCancelled = firstError !== undefined || (scope.signal.aborted && !scope.timedOut());
|
|
1249
|
+
firstError ??= scope.timedOut()
|
|
1250
|
+
? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`)
|
|
1251
|
+
: error;
|
|
1252
|
+
if (!wasCancelled) failedIndices.add(index);
|
|
1253
|
+
chunkStates[index] = {
|
|
1254
|
+
chunkIndex: index,
|
|
1255
|
+
status: wasCancelled ? "cancelled" : "failed",
|
|
1256
|
+
isOriginalFailure: !wasCancelled,
|
|
1257
|
+
canResume: true,
|
|
1258
|
+
error: serializeChunkError(error, "synthesis", !wasCancelled),
|
|
1259
|
+
};
|
|
837
1260
|
report({
|
|
838
1261
|
currentChunk: completed,
|
|
839
1262
|
totalChunks,
|
|
@@ -844,9 +1267,6 @@ export async function synthesizeSsmlChunks(
|
|
|
844
1267
|
durationMs: Date.now() - startedAt,
|
|
845
1268
|
error,
|
|
846
1269
|
});
|
|
847
|
-
firstError ??= scope.timedOut()
|
|
848
|
-
? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`)
|
|
849
|
-
: error;
|
|
850
1270
|
if (config.cancelOnFailure !== false) scope.abort();
|
|
851
1271
|
return;
|
|
852
1272
|
}
|
|
@@ -855,26 +1275,50 @@ export async function synthesizeSsmlChunks(
|
|
|
855
1275
|
try {
|
|
856
1276
|
await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
|
|
857
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);
|
|
858
1282
|
const orderedResults = results.filter((result): result is SsmlSynthesisResult => result !== undefined);
|
|
859
1283
|
return await mergeSynthesisResults(orderedResults, {
|
|
860
1284
|
format: (config.outputFormat ?? DEFAULT_OUTPUT_FORMAT) as AzureTtsOutputFormat,
|
|
861
|
-
signal:
|
|
1285
|
+
signal: deadline.signal,
|
|
862
1286
|
customMerger: config.customMerger,
|
|
863
1287
|
outputMimeType: config.outputMimeType,
|
|
864
1288
|
postMergeValidator: config.postMergeValidator,
|
|
1289
|
+
deadline,
|
|
865
1290
|
});
|
|
866
1291
|
} catch (error) {
|
|
1292
|
+
if (firstError && config.cancelOnFailure !== false) {
|
|
1293
|
+
for (const [chunkIndex, state] of chunkStates.entries()) {
|
|
1294
|
+
if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
|
|
1295
|
+
chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
const synthesizedChunks = results.flatMap((result, chunkIndex) =>
|
|
1300
|
+
result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : [],
|
|
1301
|
+
);
|
|
867
1302
|
const partial = {
|
|
868
|
-
synthesizedChunks
|
|
869
|
-
completedChunks:
|
|
870
|
-
pendingChunkIndices:
|
|
1303
|
+
synthesizedChunks,
|
|
1304
|
+
completedChunks: synthesizedChunks,
|
|
1305
|
+
pendingChunkIndices: chunkStates.flatMap((state) =>
|
|
1306
|
+
state.status === "pending" || state.status === "cancelled" || state.status === "failed"
|
|
1307
|
+
? [state.chunkIndex]
|
|
1308
|
+
: [],
|
|
1309
|
+
),
|
|
871
1310
|
failedChunkIndices: [...failedIndices],
|
|
1311
|
+
cancelledChunkIndices: chunkStates
|
|
1312
|
+
.filter((state) => state.status === "cancelled")
|
|
1313
|
+
.map((state) => state.chunkIndex),
|
|
1314
|
+
chunkStates,
|
|
872
1315
|
totalChunks,
|
|
873
1316
|
};
|
|
874
1317
|
if (error && typeof error === "object") (error as { partialResult?: unknown }).partialResult = partial;
|
|
875
1318
|
throw error;
|
|
876
1319
|
} finally {
|
|
877
1320
|
scope.dispose();
|
|
1321
|
+
deadline.dispose();
|
|
878
1322
|
}
|
|
879
1323
|
}
|
|
880
1324
|
|
|
@@ -982,35 +1426,45 @@ export function mergeSynthesisResults(
|
|
|
982
1426
|
const format = resolvedOptions?.format;
|
|
983
1427
|
if (!format) throw new UnsupportedMergeFormatError("");
|
|
984
1428
|
const buffers = results.map((result) => result.audioData);
|
|
985
|
-
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
|
+
});
|
|
986
1438
|
validateAudioSpecifications(inputSpecs);
|
|
987
|
-
const
|
|
1439
|
+
const deadline = resolvedOptions.deadline;
|
|
1440
|
+
deadline?.throwIfExpired();
|
|
1441
|
+
const signal = resolvedOptions.signal ?? deadline?.signal ?? new AbortController().signal;
|
|
988
1442
|
if (signal.aborted) throw new SynthesisCancelledError();
|
|
989
1443
|
if (resolvedOptions.customMerger) {
|
|
990
|
-
return
|
|
991
|
-
.then(() =>
|
|
1444
|
+
return withinDeadline(
|
|
1445
|
+
Promise.resolve().then(() =>
|
|
992
1446
|
resolvedOptions.customMerger?.(buffers, {
|
|
993
1447
|
format,
|
|
994
1448
|
outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
|
|
995
1449
|
inputSpecs,
|
|
996
1450
|
signal,
|
|
997
1451
|
}),
|
|
998
|
-
)
|
|
1452
|
+
),
|
|
1453
|
+
deadline,
|
|
1454
|
+
)
|
|
999
1455
|
.then((merged) => {
|
|
1456
|
+
deadline?.throwIfExpired();
|
|
1000
1457
|
if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
|
|
1001
|
-
if (
|
|
1002
|
-
!(merged instanceof ArrayBuffer) ||
|
|
1003
|
-
(buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
|
|
1004
|
-
)
|
|
1005
|
-
throw new MergeError("The custom audio merger returned an invalid audio buffer.");
|
|
1006
1458
|
if (signal.aborted) throw new SynthesisCancelledError();
|
|
1007
|
-
const
|
|
1008
|
-
results,
|
|
1459
|
+
const mergedSpec = validateMergedAudioBuffer(
|
|
1009
1460
|
merged,
|
|
1010
1461
|
format,
|
|
1011
|
-
|
|
1012
|
-
|
|
1462
|
+
buffers,
|
|
1463
|
+
inputSpecs,
|
|
1464
|
+
resolvedOptions.outputMimeType ?? resolveMimeType(format),
|
|
1465
|
+
true,
|
|
1013
1466
|
);
|
|
1467
|
+
const result = createMergedResult(results, merged, format, mergedSpec, resolvedOptions.outputMimeType);
|
|
1014
1468
|
return Promise.resolve(
|
|
1015
1469
|
resolvedOptions.postMergeValidator?.(result, {
|
|
1016
1470
|
format,
|
|
@@ -1019,6 +1473,7 @@ export function mergeSynthesisResults(
|
|
|
1019
1473
|
signal,
|
|
1020
1474
|
}),
|
|
1021
1475
|
).then((valid) => {
|
|
1476
|
+
deadline?.throwIfExpired();
|
|
1022
1477
|
if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
|
|
1023
1478
|
return result;
|
|
1024
1479
|
});
|
|
@@ -1030,6 +1485,7 @@ export function mergeSynthesisResults(
|
|
|
1030
1485
|
});
|
|
1031
1486
|
}
|
|
1032
1487
|
try {
|
|
1488
|
+
deadline?.throwIfExpired();
|
|
1033
1489
|
const result = createMergedResult(
|
|
1034
1490
|
results,
|
|
1035
1491
|
mergeAudioBuffers(buffers, { format }),
|
|
@@ -1045,12 +1501,14 @@ export function mergeSynthesisResults(
|
|
|
1045
1501
|
signal,
|
|
1046
1502
|
});
|
|
1047
1503
|
if (validation instanceof Promise)
|
|
1048
|
-
return validation.then((valid) => {
|
|
1504
|
+
return withinDeadline(validation, deadline).then((valid) => {
|
|
1505
|
+
deadline?.throwIfExpired();
|
|
1049
1506
|
if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
|
|
1050
1507
|
return result;
|
|
1051
1508
|
});
|
|
1052
1509
|
if (validation === false) throw new MergeError("The post-merge validator rejected the merged audio.");
|
|
1053
1510
|
}
|
|
1511
|
+
deadline?.throwIfExpired();
|
|
1054
1512
|
return result;
|
|
1055
1513
|
} catch (error) {
|
|
1056
1514
|
if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
|