@ssml-builder-js/azure-tts-client 2.18.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
@@ -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 function computeChunkFingerprint(ssml: string, outputFormat = DEFAULT_OUTPUT_FORMAT): string {
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
- voice: readAttribute("(?:name|voice)"),
52
- language: readAttribute("(?:xml:lang|lang)"),
53
- rate: readAttribute("rate"),
54
- pitch: readAttribute("pitch"),
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)) return parseMp3Specification(buffer, format) ?? 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
+ }
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" || specification.codec === "silk") return;
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
- const specification = inspectAudioSpecification(merged, format);
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 totalJobMs = config.timeouts?.totalJobMs;
813
- const deadlineAtMs = totalJobMs !== undefined && totalJobMs > 0 ? Date.now() + totalJobMs : undefined;
814
- if (!config.retryOptions) return synthesizeSsmlOnce(ssml, config);
815
- return synthesizeWithRetry(ssml, config, config.retryOptions, () => undefined, deadlineAtMs);
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: scope.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 as ChunkExecutionState["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: scope.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) => 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
+ });
1163
1438
  validateAudioSpecifications(inputSpecs);
1164
- 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;
1165
1442
  if (signal.aborted) throw new SynthesisCancelledError();
1166
1443
  if (resolvedOptions.customMerger) {
1167
- return Promise.resolve()
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 { AzureTtsError } from "./errors.ts";
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?: AzureTtsError | SsmlValidationError;
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;
@@ -0,0 +1,131 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ IncompleteChunkSetError,
5
+ inspectAudioSpecification,
6
+ synthesizeSsmlChunksSafe,
7
+ synthesizeSsmlSafe,
8
+ computeChunkFingerprint,
9
+ } from "../src/index.ts";
10
+
11
+ const validSsml = (text: string) =>
12
+ `<speak version="1.0" xml:lang="en-US"><voice name="en-US-JennyNeural">${text}</voice></speak>`;
13
+
14
+ function ebmlSize(size: number): Uint8Array {
15
+ if (size < 0x7f) return Uint8Array.of(0x80 | size);
16
+ if (size < 0x3fff) return Uint8Array.of(0x40 | (size >> 8), size & 0xff);
17
+ throw new Error("Test fixture is too large.");
18
+ }
19
+
20
+ function ebmlElement(id: readonly number[], data: Uint8Array): Uint8Array {
21
+ return Uint8Array.from([...id, ...ebmlSize(data.byteLength), ...data]);
22
+ }
23
+
24
+ function concatBytes(...parts: Uint8Array[]): Uint8Array {
25
+ return Uint8Array.from(parts.flatMap((part) => [...part]));
26
+ }
27
+
28
+ function oggOpus(): ArrayBuffer {
29
+ const payload = new Uint8Array(19);
30
+ payload.set(new TextEncoder().encode("OpusHead"));
31
+ payload[8] = 1;
32
+ payload[9] = 1;
33
+ new DataView(payload.buffer).setUint32(12, 16_000, true);
34
+ const page = new Uint8Array(27 + 1 + payload.byteLength);
35
+ page.set(new TextEncoder().encode("OggS"));
36
+ page[26] = 1;
37
+ page[27] = payload.byteLength;
38
+ page.set(payload, 28);
39
+ return page.buffer;
40
+ }
41
+
42
+ function webmOpus(): ArrayBuffer {
43
+ const sampling = new ArrayBuffer(8);
44
+ new DataView(sampling).setFloat64(0, 24_000, false);
45
+ const audio = ebmlElement(
46
+ [0xe1],
47
+ concatBytes(ebmlElement([0xb5], new Uint8Array(sampling)), ebmlElement([0x9f], Uint8Array.of(1))),
48
+ );
49
+ const track = ebmlElement(
50
+ [0xae],
51
+ Uint8Array.from([
52
+ ...ebmlElement([0xd7], Uint8Array.of(1)),
53
+ ...ebmlElement([0x83], Uint8Array.of(2)),
54
+ ...ebmlElement([0x86], new TextEncoder().encode("A_OPUS")),
55
+ ...audio,
56
+ ]),
57
+ );
58
+ const tracks = ebmlElement([0x16, 0x54, 0xae, 0x6b], track);
59
+ const ebml = ebmlElement([0x1a, 0x45, 0xdf, 0xa3], ebmlElement([0x42, 0x82], new TextEncoder().encode("webm")));
60
+ const segment = ebmlElement([0x18, 0x53, 0x80, 0x67], tracks);
61
+ return Uint8Array.from([...ebml, ...segment]).buffer;
62
+ }
63
+
64
+ test("fingerprints include the complete synthesis environment", () => {
65
+ const base = computeChunkFingerprint(validSsml("hello"), "audio-16khz-128kbitrate-mono-mp3", {
66
+ region: "eastus",
67
+ endpoint: "https://eastus.example.test/tts",
68
+ customHeaders: { "x-tenant": "a" },
69
+ fingerprintSchemaVersion: "2",
70
+ });
71
+ assert.notEqual(
72
+ base,
73
+ computeChunkFingerprint(validSsml("hello"), "audio-16khz-128kbitrate-mono-mp3", {
74
+ region: "japaneast",
75
+ endpoint: "https://japaneast.example.test/tts",
76
+ customHeaders: { "x-tenant": "a" },
77
+ fingerprintSchemaVersion: "2",
78
+ }),
79
+ );
80
+ assert.notEqual(
81
+ base,
82
+ computeChunkFingerprint(validSsml("hello"), "audio-16khz-128kbitrate-mono-mp3", {
83
+ region: "eastus",
84
+ endpoint: "https://eastus.example.test/tts",
85
+ customHeaders: { "x-tenant": "b" },
86
+ fingerprintSchemaVersion: "2",
87
+ }),
88
+ );
89
+ });
90
+
91
+ test("refuses to merge when resumeChunkIndices leave a chunk missing", async () => {
92
+ const fingerprint = computeChunkFingerprint(validSsml("one"));
93
+ const result = await synthesizeSsmlChunksSafe(
94
+ { synthesizeSsml: async () => ({ audioData: Uint8Array.of(1).buffer, durationMs: 1 }) },
95
+ [validSsml("one"), validSsml("two")],
96
+ {
97
+ resumeChunks: [{ chunkIndex: 0, fingerprint, audioData: Uint8Array.of(1).buffer, durationMs: 1 }],
98
+ resumeChunkIndices: [0],
99
+ },
100
+ );
101
+ assert.equal(result.ok, false);
102
+ if (!result.ok) assert.ok(result.error instanceof IncompleteChunkSetError);
103
+ });
104
+
105
+ test("applies totalJobMs to one safe synthesis before the client resolves", async () => {
106
+ const result = await synthesizeSsmlSafe(
107
+ {
108
+ synthesizeSsml: async (_ssml, options) =>
109
+ new Promise((resolve, reject) => {
110
+ const timer = setTimeout(() => resolve({ audioData: new ArrayBuffer(0), durationMs: 0 }), 100);
111
+ options?.signal?.addEventListener("abort", () => {
112
+ clearTimeout(timer);
113
+ reject(new Error("Speech synthesis was cancelled."));
114
+ });
115
+ }),
116
+ },
117
+ validSsml("slow"),
118
+ { timeouts: { totalJobMs: 10 } },
119
+ );
120
+ assert.equal(result.ok, false);
121
+ if (!result.ok) assert.equal(result.error.kind, "timeout");
122
+ });
123
+
124
+ test("validates Ogg and WebM codec headers", () => {
125
+ assert.equal(inspectAudioSpecification(oggOpus(), "ogg-16khz-16bit-mono-opus").codec, "opus");
126
+ assert.equal(inspectAudioSpecification(webmOpus(), "webm-24khz-16bit-mono-opus").container, "webm");
127
+ assert.throws(() =>
128
+ inspectAudioSpecification(Uint8Array.of(0x4f, 0x67, 0x67, 0x53).buffer, "ogg-16khz-16bit-mono-opus"),
129
+ );
130
+ assert.throws(() => inspectAudioSpecification(Uint8Array.of(0x1a, 0x45, 0xdf).buffer, "webm-24khz-16bit-mono-opus"));
131
+ });