@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/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 formatNumber(format: string, pattern: RegExp, fallback: number): number {
100
- const match = pattern.exec(format);
101
- return match?.[1] ? Number(match[1]) : fallback;
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 = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
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
- : /pcm|mulaw|alaw|siren/i.test(format)
122
- ? "pcm"
123
- : "unknown";
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 === "mp3" || codec === "opus" || codec === "silk",
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: formatCode === 1 ? "pcm" : "unknown",
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: formatCode !== 1,
417
+ isCompressed: codec !== "pcm" && codec !== "unknown",
211
418
  };
212
419
  }
213
- if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
214
- return formatAudioSpecification(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
+ }
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
- export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<SsmlSynthesisResult> {
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: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
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 synthesizeSsml(ssml, config);
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 >= 0 && index < totalChunks) results[index] = cached;
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) && (requestedIndices === undefined || requestedIndices.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, chunk] of chunks.entries()) {
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 chunk = chunks[index];
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: scope.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
- failedIndices.add(index);
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: scope.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: results.flatMap((result, chunkIndex) => (result ? [{ ...result, chunkIndex }] : [])),
869
- completedChunks: results.flatMap((result, chunkIndex) => (result ? [{ ...result, chunkIndex }] : [])),
870
- pendingChunkIndices: chunks.flatMap((_chunk, chunkIndex) => (results[chunkIndex] ? [] : [chunkIndex])),
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) => result.audioSpec ?? inspectAudioSpecification(result.audioData, format));
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 signal = resolvedOptions.signal ?? new AbortController().signal;
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 Promise.resolve()
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 result = createMergedResult(
1008
- results,
1459
+ const mergedSpec = validateMergedAudioBuffer(
1009
1460
  merged,
1010
1461
  format,
1011
- inspectAudioSpecification(merged, format),
1012
- resolvedOptions.outputMimeType,
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)