@ssml-builder-js/azure-tts-client 2.14.0 → 2.16.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/dist/index.js CHANGED
@@ -37,16 +37,23 @@ var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "
37
37
  // src/index.ts
38
38
  var index_exports = {};
39
39
  __export(index_exports, {
40
+ AudioFormatMismatchError: () => AudioFormatMismatchError,
40
41
  AzureTtsClient: () => AzureTtsClient,
41
42
  AzureTtsError: () => AzureTtsError,
42
43
  AzureTtsSdkError: () => AzureTtsSdkError,
43
44
  ChunkValidationError: () => ChunkValidationError,
45
+ DEFAULT_OUTPUT_FORMAT: () => DEFAULT_OUTPUT_FORMAT,
46
+ MergeError: () => MergeError,
47
+ SynthesisCancelledError: () => SynthesisCancelledError,
48
+ SynthesisTimeoutError: () => SynthesisTimeoutError,
44
49
  UnsupportedMergeFormatError: () => UnsupportedMergeFormatError,
45
50
  canMergeAudioFormat: () => canMergeAudioFormat,
46
51
  fetchAzureVoiceCatalog: () => fetchAzureVoiceCatalog,
52
+ inspectAudioSpecification: () => inspectAudioSpecification,
47
53
  mergeAudioBuffers: () => mergeAudioBuffers,
48
54
  mergeSynthesisResults: () => mergeSynthesisResults,
49
55
  resolveMergeAudioFormat: () => resolveMergeAudioFormat,
56
+ resolveMimeType: () => resolveMimeType,
50
57
  synthesizeSpeech: () => synthesizeSpeech,
51
58
  synthesizeSsml: () => synthesizeSsml,
52
59
  synthesizeSsmlChunks: () => synthesizeSsmlChunks,
@@ -59,6 +66,7 @@ module.exports = __toCommonJS(index_exports);
59
66
  var AzureTtsError = class extends Error {
60
67
  constructor(status, statusText, responseBody, requestId) {
61
68
  super(`Azure TTS request failed: ${status} ${statusText}`);
69
+ this.kind = "azure-api-error";
62
70
  this.name = "AzureTtsError";
63
71
  this.status = status;
64
72
  this.statusText = statusText;
@@ -74,13 +82,52 @@ var AzureTtsSdkError = class extends AzureTtsError {
74
82
  this.errorDetails = errorDetails;
75
83
  }
76
84
  };
85
+ var SynthesisCancelledError = class extends Error {
86
+ constructor(message = "Speech synthesis was cancelled.") {
87
+ super(message);
88
+ this.kind = "cancelled";
89
+ this.name = "SynthesisCancelledError";
90
+ }
91
+ };
92
+ var SynthesisTimeoutError = class extends Error {
93
+ constructor(message) {
94
+ super(message);
95
+ this.kind = "timeout";
96
+ this.name = "SynthesisTimeoutError";
97
+ }
98
+ };
99
+ var MergeError = class extends Error {
100
+ constructor(message, cause) {
101
+ super(message);
102
+ this.kind = "merge-error";
103
+ this.name = "MergeError";
104
+ this.cause = cause;
105
+ }
106
+ };
107
+ var AudioFormatMismatchError = class extends Error {
108
+ constructor(message, inputSpecs = []) {
109
+ super(message);
110
+ this.kind = "audio-format-mismatch";
111
+ this.name = "AudioFormatMismatchError";
112
+ this.inputSpecs = inputSpecs;
113
+ }
114
+ };
77
115
  var UnsupportedMergeFormatError = class extends Error {
78
116
  constructor(format) {
79
117
  super(`Audio format "${format}" cannot be safely concatenated; container re-multiplexing is required.`);
118
+ this.kind = "unsupported-format-error";
80
119
  this.name = "UnsupportedMergeFormatError";
81
120
  this.format = format;
82
121
  }
83
122
  };
123
+ function toSynthesisError(error) {
124
+ if (error instanceof AzureTtsError || error instanceof MergeError || error instanceof AudioFormatMismatchError || error instanceof UnsupportedMergeFormatError || error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError)
125
+ return error;
126
+ const message = error instanceof Error ? error.message : String(error);
127
+ if (/cancel|abort/i.test(message)) return new SynthesisCancelledError(message);
128
+ if (/tim(?:e|ed) ?out/i.test(message)) return new SynthesisTimeoutError(message);
129
+ return createSpeechSdkError(error);
130
+ }
84
131
  function createSpeechSdkError(error) {
85
132
  const message = error instanceof Error ? error.message : String(error);
86
133
  return new AzureTtsSdkError(message);
@@ -88,9 +135,7 @@ function createSpeechSdkError(error) {
88
135
 
89
136
  // src/synthesis.ts
90
137
  var SpeechSDK2 = __toESM(require("microsoft-cognitiveservices-speech-sdk"));
91
-
92
- // src/speechConfig.ts
93
- var import_microsoft_cognitiveservices_speech_sdk = require("microsoft-cognitiveservices-speech-sdk");
138
+ var import_ssml_core = require("@ssml-builder-js/ssml-core");
94
139
 
95
140
  // src/outputFormats.ts
96
141
  var SpeechSDK = __toESM(require("microsoft-cognitiveservices-speech-sdk"));
@@ -136,6 +181,14 @@ var OUTPUT_FORMATS = {
136
181
  "amr-wb-16000hz": SpeechSDK.SpeechSynthesisOutputFormat.AmrWb16000Hz,
137
182
  "g722-16khz-64kbps": SpeechSDK.SpeechSynthesisOutputFormat.G72216Khz64Kbps
138
183
  };
184
+ function resolveMimeType(outputFormat) {
185
+ if (/(?:wav|wave|riff)/i.test(outputFormat)) return "audio/wav";
186
+ if (/(?:mp3|mpeg)/i.test(outputFormat)) return "audio/mpeg";
187
+ if (/ogg/i.test(outputFormat)) return "audio/ogg";
188
+ if (/webm/i.test(outputFormat)) return "audio/webm";
189
+ if (/raw/i.test(outputFormat)) return "audio/L16";
190
+ return "application/octet-stream";
191
+ }
139
192
  function resolveOutputFormat(outputFormat) {
140
193
  const resolvedFormat = OUTPUT_FORMATS[outputFormat];
141
194
  if (resolvedFormat === void 0) {
@@ -145,6 +198,7 @@ function resolveOutputFormat(outputFormat) {
145
198
  }
146
199
 
147
200
  // src/speechConfig.ts
201
+ var import_microsoft_cognitiveservices_speech_sdk = require("microsoft-cognitiveservices-speech-sdk");
148
202
  function resolveEndpoint(config) {
149
203
  const endpoint = config.endpoint?.trim() || "https://{region}.tts.speech.microsoft.com/cognitiveservices/v1";
150
204
  return endpoint.replace(/\{region\}/g, encodeURIComponent(config.region));
@@ -197,6 +251,105 @@ function parseWav(buffer) {
197
251
  }
198
252
  return { chunks, data, format };
199
253
  }
254
+ function formatNumber(format, pattern, fallback) {
255
+ const match = pattern.exec(format);
256
+ return match?.[1] ? Number(match[1]) : fallback;
257
+ }
258
+ function formatChannels(format, fallback) {
259
+ if (/stereo|2ch|dual/i.test(format)) return 2;
260
+ if (/mono|1ch/i.test(format)) return 1;
261
+ return fallback;
262
+ }
263
+ function formatAudioSpecification(format) {
264
+ const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
265
+ const channels = formatChannels(format, 0);
266
+ const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
267
+ const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1e3 : void 0;
268
+ const codec = /mp3|mpeg/i.test(format) ? "mp3" : /opus/i.test(format) ? "opus" : /silk/i.test(format) ? "silk" : /pcm|mulaw|alaw|siren/i.test(format) ? "pcm" : "unknown";
269
+ return {
270
+ format,
271
+ mimeType: resolveMimeType(format),
272
+ codec,
273
+ sampleRate,
274
+ channels,
275
+ ...bitrate ? { bitrate } : {},
276
+ isCompressed: codec === "mp3" || codec === "opus" || codec === "silk"
277
+ };
278
+ }
279
+ function parseMp3Specification(buffer, format) {
280
+ const bytes = stripMp3Tags(buffer);
281
+ const bitrates = [
282
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
283
+ [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0],
284
+ [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0]
285
+ ];
286
+ const sampleRates = [
287
+ [44100, 48e3, 32e3],
288
+ [22050, 24e3, 16e3],
289
+ [11025, 12e3, 8e3]
290
+ ];
291
+ for (let index = 0; index + 4 <= bytes.length; index += 1) {
292
+ if (bytes[index] !== 255 || (bytes[index + 1] ?? 0) < 224) continue;
293
+ const header = bytes[index + 1] ?? 0;
294
+ const versionBits = header >> 3 & 3;
295
+ const layer = header >> 1 & 3;
296
+ const bitrateIndex = (bytes[index + 2] ?? 0) >> 4;
297
+ const sampleIndex = (bytes[index + 2] ?? 0) >> 2 & 3;
298
+ if (versionBits === 1 || layer !== 1 || bitrateIndex === 0 || bitrateIndex === 15 || sampleIndex === 3) continue;
299
+ const versionIndex = versionBits === 3 ? 0 : versionBits === 2 ? 1 : 2;
300
+ const bitrateTable = versionBits === 3 ? bitrates[1] : bitrates[2];
301
+ const sampleRate = sampleRates[versionIndex]?.[sampleIndex] ?? 0;
302
+ const bitrateKbps = bitrateTable?.[bitrateIndex] ?? 0;
303
+ if (!sampleRate || !bitrateKbps) continue;
304
+ return {
305
+ format,
306
+ mimeType: "audio/mpeg",
307
+ codec: "mp3",
308
+ sampleRate,
309
+ channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
310
+ bitrate: bitrateKbps * 1e3,
311
+ isCompressed: true
312
+ };
313
+ }
314
+ return void 0;
315
+ }
316
+ function inspectAudioSpecification(buffer, format) {
317
+ if (isWavFormat(format) || ascii(new Uint8Array(buffer), 0, "RIFF")) {
318
+ const parsed = parseWav(buffer);
319
+ if (parsed.format.byteLength < 16) throw new Error("Invalid WAV fmt chunk.");
320
+ const view = new DataView(parsed.format.buffer, parsed.format.byteOffset, parsed.format.byteLength);
321
+ const sampleRate = view.getUint32(4, true);
322
+ const channels = view.getUint16(2, true);
323
+ const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
324
+ const formatCode = view.getUint16(0, true);
325
+ return {
326
+ format,
327
+ mimeType: "audio/wav",
328
+ codec: formatCode === 1 ? "pcm" : "unknown",
329
+ sampleRate,
330
+ channels,
331
+ ...sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {},
332
+ isCompressed: formatCode !== 1
333
+ };
334
+ }
335
+ if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
336
+ return formatAudioSpecification(format);
337
+ }
338
+ function validateAudioSpecifications(specs) {
339
+ const first = specs[0];
340
+ if (!first) return;
341
+ const mismatch = specs.find(
342
+ (spec) => spec.sampleRate !== first.sampleRate || spec.channels !== first.channels || first.bitrate !== void 0 && spec.bitrate !== void 0 && spec.bitrate !== first.bitrate
343
+ );
344
+ if (mismatch)
345
+ throw new AudioFormatMismatchError(
346
+ `Audio chunks have incompatible specifications: ${first.sampleRate}Hz/${first.channels}ch versus ${mismatch.sampleRate}Hz/${mismatch.channels}ch.`,
347
+ specs
348
+ );
349
+ }
350
+ function isAudioFormatMismatch(error) {
351
+ return error instanceof AudioFormatMismatchError || error !== null && typeof error === "object" && "kind" in error && error.kind === "audio-format-mismatch";
352
+ }
200
353
  function writeUint32(target, offset, value) {
201
354
  new DataView(target.buffer).setUint32(offset, value, true);
202
355
  }
@@ -278,28 +431,37 @@ function resolveMergeAudioFormat(format) {
278
431
  function canMergeAudioFormat(format) {
279
432
  return resolveMergeAudioFormat(format) !== void 0;
280
433
  }
281
- function mergeAudioBuffers(buffers, format) {
282
- if (isWavFormat(format)) return mergeWavBuffers(buffers);
283
- if (isMp3Format(format)) {
284
- const parts = buffers.map(stripMp3Tags);
285
- const output = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0));
286
- let offset = 0;
287
- for (const part of parts) {
288
- output.set(part, offset);
289
- offset += part.byteLength;
434
+ function mergeAudioBuffers(buffers, options) {
435
+ const format = typeof options === "string" ? options : options?.format;
436
+ if (!format) throw new UnsupportedMergeFormatError("");
437
+ try {
438
+ validateAudioSpecifications(buffers.map((buffer) => inspectAudioSpecification(buffer, format)));
439
+ if (isWavFormat(format)) return mergeWavBuffers(buffers);
440
+ if (isMp3Format(format)) {
441
+ const parts = buffers.map(stripMp3Tags);
442
+ const output = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0));
443
+ let offset = 0;
444
+ for (const part of parts) {
445
+ output.set(part, offset);
446
+ offset += part.byteLength;
447
+ }
448
+ return output.buffer;
290
449
  }
291
- return output.buffer;
292
- }
293
- if (isRawFormat(format)) {
294
- const output = new Uint8Array(buffers.reduce((total, buffer) => total + buffer.byteLength, 0));
295
- let offset = 0;
296
- for (const buffer of buffers) {
297
- output.set(new Uint8Array(buffer), offset);
298
- offset += buffer.byteLength;
450
+ if (isRawFormat(format)) {
451
+ const output = new Uint8Array(buffers.reduce((total, buffer) => total + buffer.byteLength, 0));
452
+ let offset = 0;
453
+ for (const buffer of buffers) {
454
+ output.set(new Uint8Array(buffer), offset);
455
+ offset += buffer.byteLength;
456
+ }
457
+ return output.buffer;
299
458
  }
300
- return output.buffer;
459
+ throw new UnsupportedMergeFormatError(format);
460
+ } catch (error) {
461
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
462
+ throw error;
463
+ throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
301
464
  }
302
- throw new UnsupportedMergeFormatError(format);
303
465
  }
304
466
  function closeSpeechResources(speechConfig, synthesizer) {
305
467
  try {
@@ -314,7 +476,7 @@ function closeSpeechResources(speechConfig, synthesizer) {
314
476
  var ticksToMilliseconds = (ticks) => Math.max(0, ticks) / 1e4;
315
477
  async function synthesizeSsml(ssml, config) {
316
478
  if (config.signal?.aborted) {
317
- throw createSpeechSdkError("Speech synthesis was cancelled.");
479
+ throw new SynthesisCancelledError();
318
480
  }
319
481
  const speechConfig = createSpeechConfig(config);
320
482
  const synthesizer = new SpeechSDK2.SpeechSynthesizer(speechConfig, null);
@@ -337,23 +499,104 @@ async function synthesizeSsml(ssml, config) {
337
499
  settled = true;
338
500
  cleanup();
339
501
  closeResources();
340
- reject(createSpeechSdkError(error));
502
+ reject(toSynthesisError(error));
341
503
  };
342
504
  const boundaries = [];
343
505
  const visemes = [];
344
506
  const bookmarks = [];
507
+ let sourceEventCursor = 0;
508
+ let generatedSourceMap;
509
+ if (!config.sourceTextSegments && !config.sourceMarkers) {
510
+ try {
511
+ generatedSourceMap = (0, import_ssml_core.getSsmlSourceMap)(ssml);
512
+ } catch {
513
+ generatedSourceMap = void 0;
514
+ }
515
+ }
516
+ const sourceBaseOffset = config.sourceTextRange?.start ?? 0;
517
+ const sourceSegments = config.sourceTextSegments ?? generatedSourceMap?.segments.map((segment) => ({
518
+ ...segment,
519
+ range: {
520
+ start: segment.range.start + sourceBaseOffset,
521
+ end: segment.range.end + sourceBaseOffset
522
+ },
523
+ sourceNodePath: [...segment.sourceNodePath]
524
+ })) ?? [];
525
+ const sourceMarkers = config.sourceMarkers ?? generatedSourceMap?.markers.map((marker) => ({
526
+ ...marker,
527
+ originalTextRange: {
528
+ start: marker.originalTextRange.start + sourceBaseOffset,
529
+ end: marker.originalTextRange.end + sourceBaseOffset
530
+ },
531
+ sourceNodePath: [...marker.sourceNodePath]
532
+ })) ?? [];
533
+ const sourceText = sourceSegments.map((segment) => segment.text).join("");
534
+ const mapSourceEvent = (text, offsetHint, markerName) => {
535
+ const marker = markerName ? sourceMarkers.find((candidate) => candidate.name === markerName) : void 0;
536
+ if (marker) {
537
+ return {
538
+ originalTextRange: { ...marker.originalTextRange },
539
+ sourceNodePath: [...marker.sourceNodePath],
540
+ textRange: { ...marker.originalTextRange },
541
+ mappingStatus: "exact"
542
+ };
543
+ }
544
+ if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) {
545
+ const unmapped = { mappingStatus: "unmapped" };
546
+ Object.defineProperty(unmapped, "mappingStatus", { value: "unmapped", enumerable: false });
547
+ return unmapped;
548
+ }
549
+ const value = text ?? "";
550
+ let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? offsetHint : -1;
551
+ let mappingStatus = "exact";
552
+ if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value) {
553
+ localStart = -1;
554
+ mappingStatus = "fallback";
555
+ }
556
+ if (localStart < 0 || localStart > sourceText.length) {
557
+ localStart = value ? sourceText.indexOf(value, sourceEventCursor) : sourceEventCursor;
558
+ if (localStart < 0) localStart = value ? sourceText.indexOf(value) : sourceEventCursor;
559
+ mappingStatus = "fallback";
560
+ }
561
+ localStart = Math.max(0, localStart);
562
+ const localEnd = Math.min(sourceText.length, localStart + value.length);
563
+ sourceEventCursor = Math.max(sourceEventCursor, localEnd);
564
+ const baseStart = config.sourceTextRange?.start ?? sourceSegments[0]?.range.start ?? 0;
565
+ const fallbackRange = { start: baseStart + localStart, end: baseStart + localEnd };
566
+ const segment = sourceSegments.find(({ range }) => range.start <= fallbackRange.start && range.end > fallbackRange.start) ?? sourceSegments.find(({ range }) => range.end > fallbackRange.start) ?? (value.length === 0 ? sourceSegments.find(({ range }) => range.start <= fallbackRange.start && range.end >= fallbackRange.start) : void 0);
567
+ return {
568
+ originalTextRange: { ...fallbackRange },
569
+ textRange: { ...fallbackRange },
570
+ ...segment ? { sourceNodePath: [...segment.sourceNodePath] } : config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
571
+ mappingStatus: segment || config.sourceTextRange || config.sourceNodePath ? mappingStatus : "unmapped"
572
+ };
573
+ };
345
574
  synthesizer.wordBoundary = (_sender, event) => {
346
575
  boundaries.push({
347
576
  text: event.text,
348
577
  audioOffsetMs: ticksToMilliseconds(event.audioOffset),
349
- durationMs: ticksToMilliseconds(event.duration)
578
+ durationMs: ticksToMilliseconds(event.duration),
579
+ ...mapSourceEvent(
580
+ event.text,
581
+ event.textOffset
582
+ )
350
583
  });
351
584
  };
352
585
  synthesizer.visemeReceived = (_sender, event) => {
353
- visemes.push({ visemeId: event.visemeId, audioOffsetMs: ticksToMilliseconds(event.audioOffset) });
586
+ const eventWithOffset = event;
587
+ visemes.push({
588
+ visemeId: event.visemeId,
589
+ audioOffsetMs: ticksToMilliseconds(event.audioOffset),
590
+ ...mapSourceEvent(void 0, eventWithOffset.textOffset)
591
+ });
354
592
  };
355
593
  synthesizer.bookmarkReached = (_sender, event) => {
356
- bookmarks.push({ name: event.text, audioOffsetMs: ticksToMilliseconds(event.audioOffset) });
594
+ const eventWithOffset = event;
595
+ bookmarks.push({
596
+ name: event.text,
597
+ audioOffsetMs: ticksToMilliseconds(event.audioOffset),
598
+ ...mapSourceEvent(void 0, eventWithOffset.textOffset, event.text)
599
+ });
357
600
  };
358
601
  const cb = (result) => {
359
602
  if (settled) return;
@@ -374,20 +617,27 @@ async function synthesizeSsml(ssml, config) {
374
617
  );
375
618
  const durationMs = result.audioDuration ? ticksToMilliseconds(result.audioDuration) : eventDurationMs;
376
619
  const requestId = result.resultId;
377
- const addSourceMetadata = (event) => ({
378
- ...event,
379
- ...config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {},
380
- ...config.sourceTextRange ? { originalTextRange: { ...config.sourceTextRange } } : {},
381
- ...config.chunkIndex !== void 0 ? { chunkIndex: config.chunkIndex } : {},
382
- ...config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
383
- ...requestId ? { requestId } : {}
384
- });
620
+ const addSourceMetadata = (event) => {
621
+ const mapped = {
622
+ ...event,
623
+ ...config.sourceTextRange && !("textRange" in event) ? { textRange: { ...config.sourceTextRange } } : {},
624
+ ...config.sourceTextRange && !("originalTextRange" in event) ? { originalTextRange: { ...config.sourceTextRange } } : {},
625
+ ...config.chunkIndex !== void 0 ? { chunkIndex: config.chunkIndex } : {},
626
+ ...config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
627
+ ...requestId ? { requestId } : {}
628
+ };
629
+ if (event.mappingStatus === "unmapped")
630
+ Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
631
+ return mapped;
632
+ };
385
633
  const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
386
634
  const sourceVisemes = visemes.map((viseme) => addSourceMetadata(viseme));
387
635
  const sourceBookmarks = bookmarks.map((bookmark) => addSourceMetadata(bookmark));
388
636
  resolve({
389
637
  audioData: result.audioData,
390
638
  durationMs,
639
+ audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
640
+ mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
391
641
  ...config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {},
392
642
  ...requestId ? { requestId } : {},
393
643
  ...sourceBoundaries.length > 0 ? { boundaries: sourceBoundaries, wordBoundary: sourceBoundaries, wordBoundaries: sourceBoundaries } : {},
@@ -397,12 +647,12 @@ async function synthesizeSsml(ssml, config) {
397
647
  };
398
648
  try {
399
649
  if (config.signal) {
400
- abortHandler = () => rejectWithError("Speech synthesis was cancelled.");
650
+ abortHandler = () => rejectWithError(new SynthesisCancelledError());
401
651
  config.signal.addEventListener("abort", abortHandler, { once: true });
402
652
  }
403
653
  if (config.timeoutMs !== void 0 && config.timeoutMs > 0) {
404
654
  timeout = setTimeout(
405
- () => rejectWithError(`Speech synthesis timed out after ${config.timeoutMs} ms.`),
655
+ () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeoutMs} ms.`)),
406
656
  config.timeoutMs
407
657
  );
408
658
  }
@@ -412,8 +662,66 @@ async function synthesizeSsml(ssml, config) {
412
662
  }
413
663
  });
414
664
  }
665
+ function isRetryableSynthesisError(error) {
666
+ if (error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError) return false;
667
+ if (error instanceof AzureTtsError && error.status !== 0)
668
+ return error.status === 429 || error.status >= 500 && error.status < 600;
669
+ const message = error instanceof Error ? error.message : String(error);
670
+ if (/\b4\d{2}\b/.test(message)) return false;
671
+ const status = error && typeof error === "object" && "status" in error ? error.status : void 0;
672
+ if (typeof status === "number") return status === 429 || status >= 500 && status < 600;
673
+ return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
674
+ }
675
+ function retryDelay(options, retryAttempt) {
676
+ const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
677
+ return Math.floor(Math.random() * (base + 1));
678
+ }
679
+ function resolveConcurrency(value, total) {
680
+ if (value === void 0) return 1;
681
+ if (value === Infinity) return Math.max(1, total);
682
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
683
+ }
684
+ async function waitForRetry(delayMs, signal) {
685
+ if (signal?.aborted) throw new SynthesisCancelledError();
686
+ if (delayMs <= 0) return;
687
+ await new Promise((resolve, reject) => {
688
+ let timer;
689
+ const abort = () => {
690
+ clearTimeout(timer);
691
+ signal?.removeEventListener("abort", abort);
692
+ reject(new SynthesisCancelledError());
693
+ };
694
+ timer = setTimeout(() => {
695
+ signal?.removeEventListener("abort", abort);
696
+ resolve();
697
+ }, delayMs);
698
+ if (signal) {
699
+ signal.addEventListener("abort", abort, { once: true });
700
+ }
701
+ });
702
+ }
703
+ async function synthesizeWithRetry(ssml, config, retryOptions, onRetry) {
704
+ const options = retryOptions ? {
705
+ maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
706
+ initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
707
+ maxDelayMs: Math.max(0, retryOptions.maxDelayMs)
708
+ } : void 0;
709
+ let attempt = 0;
710
+ while (true) {
711
+ if (config.signal?.aborted) throw new SynthesisCancelledError();
712
+ try {
713
+ return await synthesizeSsml(ssml, config);
714
+ } catch (error) {
715
+ if (!options || attempt >= options.maxRetries || !isRetryableSynthesisError(error)) throw error;
716
+ attempt += 1;
717
+ const delayMs = retryDelay(options, attempt);
718
+ onRetry(attempt, delayMs);
719
+ await waitForRetry(delayMs, config.signal);
720
+ }
721
+ }
722
+ }
415
723
  async function synthesizeSsmlChunks(chunks, config) {
416
- const results = [];
724
+ const results = new Array(chunks.length);
417
725
  const totalChunks = chunks.length;
418
726
  const report = (event) => config.onProgress?.(event);
419
727
  for (const [index, chunk] of chunks.entries()) {
@@ -428,71 +736,90 @@ async function synthesizeSsmlChunks(chunks, config) {
428
736
  durationMs: 0
429
737
  });
430
738
  }
431
- for (const [index, chunk] of chunks.entries()) {
432
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
433
- report({
434
- currentChunk: index,
435
- totalChunks,
436
- percent: totalChunks === 0 ? 100 : Math.round(index / totalChunks * 100),
437
- chunkIndex: index,
438
- originalTextRange: input.originalTextRange,
439
- status: "synthesizing",
440
- durationMs: 0
441
- });
442
- const startedAt = Date.now();
443
- try {
444
- const result = await synthesizeSsml(input.ssml, {
445
- ...config,
446
- ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
447
- ...input.sourceNodePath ? { sourceNodePath: input.sourceNodePath } : {},
448
- chunkIndex: index,
449
- onProgress: void 0
450
- });
451
- results.push(result);
452
- report({
453
- currentChunk: index + 1,
454
- totalChunks,
455
- percent: totalChunks === 0 ? 100 : Math.round((index + 1) / totalChunks * 100),
456
- chunkIndex: index,
457
- originalTextRange: input.originalTextRange,
458
- status: "success",
459
- durationMs: Date.now() - startedAt
460
- });
461
- } catch (error) {
739
+ let completed = 0;
740
+ let nextIndex = 0;
741
+ const concurrency = resolveConcurrency(config.concurrency, chunks.length);
742
+ const worker = async () => {
743
+ while (true) {
744
+ const index = nextIndex++;
745
+ if (index >= chunks.length) return;
746
+ const chunk = chunks[index];
747
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
462
748
  report({
463
- currentChunk: index,
749
+ currentChunk: completed,
464
750
  totalChunks,
465
- percent: totalChunks === 0 ? 100 : Math.round(index / totalChunks * 100),
751
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
466
752
  chunkIndex: index,
467
753
  originalTextRange: input.originalTextRange,
468
- status: "failed",
469
- durationMs: Date.now() - startedAt,
470
- error
754
+ status: "synthesizing",
755
+ durationMs: 0
471
756
  });
472
- throw error;
473
- }
474
- }
475
- return mergeSynthesisResults(results, config.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3");
476
- }
477
- function mergeSynthesisResults(results, format) {
478
- const audioData = format ? new Uint8Array(
479
- mergeAudioBuffers(
480
- results.map((result) => result.audioData),
481
- format
482
- )
483
- ) : new Uint8Array(results.reduce((total, result) => total + result.audioData.byteLength, 0));
484
- if (!format) {
485
- let offset = 0;
486
- for (const result of results) {
487
- audioData.set(new Uint8Array(result.audioData), offset);
488
- offset += result.audioData.byteLength;
757
+ const startedAt = Date.now();
758
+ try {
759
+ const result = await synthesizeWithRetry(
760
+ input.ssml,
761
+ {
762
+ ...config,
763
+ ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
764
+ ...input.sourceNodePath ?? config.sourceNodePath ? { sourceNodePath: [...input.sourceNodePath ?? config.sourceNodePath ?? []] } : {},
765
+ ...input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {},
766
+ ...input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {},
767
+ chunkIndex: index,
768
+ onProgress: void 0
769
+ },
770
+ config.retryOptions,
771
+ (retryAttempt, nextRetryDelayMs) => report({
772
+ currentChunk: completed,
773
+ totalChunks,
774
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
775
+ chunkIndex: index,
776
+ originalTextRange: input.originalTextRange,
777
+ status: "synthesizing",
778
+ durationMs: Date.now() - startedAt,
779
+ retryAttempt,
780
+ nextRetryDelayMs,
781
+ isRetrying: true
782
+ })
783
+ );
784
+ results[index] = result;
785
+ completed += 1;
786
+ report({
787
+ currentChunk: completed,
788
+ totalChunks,
789
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
790
+ chunkIndex: index,
791
+ originalTextRange: input.originalTextRange,
792
+ status: "success",
793
+ durationMs: Date.now() - startedAt
794
+ });
795
+ } catch (error) {
796
+ report({
797
+ currentChunk: completed,
798
+ totalChunks,
799
+ percent: totalChunks === 0 ? 100 : Math.round(completed / totalChunks * 100),
800
+ chunkIndex: index,
801
+ originalTextRange: input.originalTextRange,
802
+ status: "failed",
803
+ durationMs: Date.now() - startedAt,
804
+ error
805
+ });
806
+ throw error;
807
+ }
489
808
  }
490
- }
809
+ };
810
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
811
+ const orderedResults = results.filter((result) => result !== void 0);
812
+ return mergeSynthesisResults(orderedResults, {
813
+ format: config.outputFormat ?? DEFAULT_OUTPUT_FORMAT,
814
+ signal: config.signal
815
+ });
816
+ }
817
+ function createMergedResult(results, audioData, format, audioSpec, outputMimeType) {
491
818
  const boundaries = [];
492
819
  const visemes = [];
493
820
  const bookmarks = [];
494
821
  let durationOffset = 0;
495
- for (const result of results) {
822
+ for (const [resultIndex, result] of results.entries()) {
496
823
  const chunkBoundaries = result.boundaries && result.boundaries.length > 0 ? result.boundaries : result.wordBoundary ?? result.wordBoundaries ?? [];
497
824
  for (const boundary of chunkBoundaries) {
498
825
  const textRange = boundary.textRange ?? result.textRange;
@@ -502,11 +829,12 @@ function mergeSynthesisResults(results, format) {
502
829
  ...boundary,
503
830
  audioOffsetMs: boundary.audioOffsetMs + durationOffset,
504
831
  chunkAudioOffsetMs: boundary.chunkAudioOffsetMs ?? boundary.audioOffsetMs,
505
- ...boundary.chunkIndex === void 0 ? { chunkIndex: results.indexOf(result) } : {},
832
+ ...boundary.chunkIndex === void 0 ? { chunkIndex: resultIndex } : {},
506
833
  ...boundary.sourceNodePath ? { sourceNodePath: [...boundary.sourceNodePath] } : {},
507
834
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
508
835
  ...textRange ? { textRange: { ...textRange } } : {},
509
- ...requestId ? { requestId } : {}
836
+ ...requestId ? { requestId } : {},
837
+ mappingStatus: boundary.mappingStatus ?? "unmapped"
510
838
  });
511
839
  }
512
840
  for (const viseme of result.visemes ?? []) {
@@ -517,11 +845,12 @@ function mergeSynthesisResults(results, format) {
517
845
  ...viseme,
518
846
  audioOffsetMs: viseme.audioOffsetMs + durationOffset,
519
847
  chunkAudioOffsetMs: viseme.chunkAudioOffsetMs ?? viseme.audioOffsetMs,
520
- ...viseme.chunkIndex === void 0 ? { chunkIndex: results.indexOf(result) } : {},
848
+ ...viseme.chunkIndex === void 0 ? { chunkIndex: resultIndex } : {},
521
849
  ...viseme.sourceNodePath ? { sourceNodePath: [...viseme.sourceNodePath] } : {},
522
850
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
523
851
  ...textRange ? { textRange: { ...textRange } } : {},
524
- ...requestId ? { requestId } : {}
852
+ ...requestId ? { requestId } : {},
853
+ mappingStatus: viseme.mappingStatus ?? "unmapped"
525
854
  });
526
855
  }
527
856
  for (const bookmark of result.bookmarks ?? []) {
@@ -532,18 +861,22 @@ function mergeSynthesisResults(results, format) {
532
861
  ...bookmark,
533
862
  audioOffsetMs: bookmark.audioOffsetMs + durationOffset,
534
863
  chunkAudioOffsetMs: bookmark.chunkAudioOffsetMs ?? bookmark.audioOffsetMs,
535
- ...bookmark.chunkIndex === void 0 ? { chunkIndex: results.indexOf(result) } : {},
864
+ ...bookmark.chunkIndex === void 0 ? { chunkIndex: resultIndex } : {},
536
865
  ...bookmark.sourceNodePath ? { sourceNodePath: [...bookmark.sourceNodePath] } : {},
537
866
  ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
538
867
  ...textRange ? { textRange: { ...textRange } } : {},
539
- ...requestId ? { requestId } : {}
868
+ ...requestId ? { requestId } : {},
869
+ mappingStatus: bookmark.mappingStatus ?? "unmapped"
540
870
  });
541
871
  }
542
872
  durationOffset += Math.max(0, result.durationMs);
543
873
  }
544
874
  return {
545
- audioData: audioData.buffer,
875
+ audioData,
546
876
  durationMs: durationOffset,
877
+ mimeType: resolveMimeType(format),
878
+ audioSpec: audioSpec ?? formatAudioSpecification(format),
879
+ ...outputMimeType ? { mimeType: outputMimeType } : {},
547
880
  ...boundaries.length > 0 ? { boundaries, wordBoundary: boundaries, wordBoundaries: boundaries } : {},
548
881
  ...visemes.length > 0 ? { visemes } : {},
549
882
  ...bookmarks.length > 0 ? { bookmarks } : {},
@@ -551,46 +884,171 @@ function mergeSynthesisResults(results, format) {
551
884
  ...results.length === 1 && results[0]?.textRange ? { textRange: { ...results[0].textRange } } : {}
552
885
  };
553
886
  }
887
+ function mergeSynthesisResults(results, options) {
888
+ const resolvedOptions = typeof options === "string" ? { format: options } : options;
889
+ const format = resolvedOptions?.format;
890
+ if (!format) throw new UnsupportedMergeFormatError("");
891
+ const buffers = results.map((result) => result.audioData);
892
+ const inputSpecs = results.map((result) => result.audioSpec ?? inspectAudioSpecification(result.audioData, format));
893
+ validateAudioSpecifications(inputSpecs);
894
+ const signal = resolvedOptions.signal ?? new AbortController().signal;
895
+ if (signal.aborted) throw new SynthesisCancelledError();
896
+ if (resolvedOptions.customMerger) {
897
+ return Promise.resolve().then(
898
+ () => resolvedOptions.customMerger?.(buffers, {
899
+ format,
900
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
901
+ inputSpecs,
902
+ signal
903
+ })
904
+ ).then((merged) => {
905
+ if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
906
+ if (!(merged instanceof ArrayBuffer) || buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
907
+ throw new MergeError("The custom audio merger returned an invalid audio buffer.");
908
+ if (signal.aborted) throw new SynthesisCancelledError();
909
+ return createMergedResult(
910
+ results,
911
+ merged,
912
+ format,
913
+ inspectAudioSpecification(merged, format),
914
+ resolvedOptions.outputMimeType
915
+ );
916
+ }).catch((error) => {
917
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
918
+ throw error;
919
+ throw new MergeError(`Custom audio merger failed for format "${format}".`, error);
920
+ });
921
+ }
922
+ try {
923
+ return createMergedResult(
924
+ results,
925
+ mergeAudioBuffers(buffers, { format }),
926
+ format,
927
+ inputSpecs[0],
928
+ resolvedOptions.outputMimeType
929
+ );
930
+ } catch (error) {
931
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
932
+ throw error;
933
+ throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
934
+ }
935
+ }
554
936
  async function synthesizeSpeech(ssml, config) {
555
937
  return (await synthesizeSsml(ssml, config)).audioData;
556
938
  }
557
939
 
558
940
  // src/safe.ts
559
- var import_ssml_core = require("@ssml-builder-js/ssml-core");
941
+ var import_ssml_core2 = require("@ssml-builder-js/ssml-core");
560
942
  var ChunkValidationError = class extends Error {
561
943
  constructor(chunkIndex, diagnostics) {
562
944
  super(`SSML validation failed for chunk ${chunkIndex}; the Azure Speech API was not called.`);
563
- this.kind = "chunk-validation";
945
+ this.kind = "validation-error";
564
946
  this.name = "ChunkValidationError";
565
947
  this.chunkIndex = chunkIndex;
566
948
  this.diagnostics = diagnostics;
567
949
  }
568
950
  };
951
+ function failure(error) {
952
+ return { ok: false, success: false, status: error.kind, error };
953
+ }
954
+ function isRetryable(error) {
955
+ if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
956
+ const status = error && typeof error === "object" && "status" in error ? error.status : void 0;
957
+ if (typeof status === "number" && status !== 0) return status === 429 || status >= 500 && status < 600;
958
+ const message = error instanceof Error ? error.message : String(error);
959
+ if (/\b4\d{2}\b/.test(message)) return false;
960
+ return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
961
+ }
962
+ function delayForRetry(options, attempt) {
963
+ const maxDelay = Math.max(0, options.maxDelayMs);
964
+ const base = Math.min(maxDelay, Math.max(0, options.initialDelayMs) * 2 ** Math.max(0, attempt - 1));
965
+ return Math.floor(Math.random() * (base + 1));
966
+ }
967
+ function resolveConcurrency2(value, total) {
968
+ if (value === void 0) return 1;
969
+ if (value === Infinity) return Math.max(1, total);
970
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
971
+ }
972
+ async function retryableSynthesis(synthesize, options, signal, onRetry) {
973
+ const retry = options ? {
974
+ maxRetries: Math.max(0, Math.floor(options.maxRetries)),
975
+ initialDelayMs: options.initialDelayMs,
976
+ maxDelayMs: options.maxDelayMs
977
+ } : void 0;
978
+ let attempt = 0;
979
+ while (true) {
980
+ if (signal?.aborted) throw new Error("Speech synthesis was cancelled.");
981
+ try {
982
+ return await synthesize();
983
+ } catch (error) {
984
+ if (!retry || attempt >= retry.maxRetries || !isRetryable(error)) throw error;
985
+ attempt += 1;
986
+ const delayMs = delayForRetry(retry, attempt);
987
+ onRetry(attempt, delayMs);
988
+ if (delayMs > 0)
989
+ await new Promise((resolve, reject) => {
990
+ const timer = setTimeout(() => {
991
+ signal?.removeEventListener("abort", abort);
992
+ resolve();
993
+ }, delayMs);
994
+ const abort = () => {
995
+ clearTimeout(timer);
996
+ signal?.removeEventListener("abort", abort);
997
+ reject(new Error("Speech synthesis was cancelled."));
998
+ };
999
+ signal?.addEventListener("abort", abort, { once: true });
1000
+ });
1001
+ }
1002
+ }
1003
+ }
1004
+ function sharedValidationOptions(options, signal) {
1005
+ const validator = options.urlValidator ?? options.customUrlValidator;
1006
+ if (!validator) return signal ? withValidationSignal(options, signal) : options;
1007
+ const runner = (0, import_ssml_core2.createAzureUrlValidatorRunner)(validator, {
1008
+ ...options.urlValidation ?? {},
1009
+ ...options.urlValidatorConcurrency !== void 0 ? { concurrency: options.urlValidatorConcurrency } : {},
1010
+ ...options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
1011
+ ...signal ? { signal } : {},
1012
+ ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
1013
+ });
1014
+ return {
1015
+ ...withValidationSignal(options, signal),
1016
+ urlValidatorRunner: runner
1017
+ };
1018
+ }
569
1019
  async function synthesizeSsmlSafe(client, ssml, options = {}) {
570
- const validationOptions = options.validation ?? options;
571
- const diagnostics = await Promise.resolve((0, import_ssml_core.validateAzureSsml)(ssml, validationOptions));
1020
+ const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
1021
+ const diagnostics = await Promise.resolve((0, import_ssml_core2.validateAzureSsml)(ssml, validationOptions));
1022
+ if (options.signal?.aborted) {
1023
+ const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
1024
+ return failure(error);
1025
+ }
572
1026
  const errors = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
573
1027
  if (errors.length > 0) {
574
- return {
575
- ok: false,
576
- success: false,
577
- status: "validation-error",
578
- error: {
579
- kind: "validation",
580
- message: "SSML validation failed; the Azure Speech API was not called.",
581
- diagnostics: errors
582
- }
583
- };
1028
+ return failure({
1029
+ kind: "validation-error",
1030
+ message: "SSML validation failed; the Azure Speech API was not called.",
1031
+ diagnostics: errors
1032
+ });
584
1033
  }
585
1034
  try {
586
- return { ok: true, success: true, status: "success", value: await client.synthesizeSsml(ssml) };
1035
+ return {
1036
+ ok: true,
1037
+ success: true,
1038
+ status: "success",
1039
+ value: await client.synthesizeSsml(ssml, { signal: options.signal })
1040
+ };
587
1041
  } catch (error) {
588
- const azureError = error instanceof AzureTtsError ? error : createSpeechSdkError(error);
589
- return { ok: false, success: false, status: "azure-api-error", error: azureError };
1042
+ const synthesisError = toSynthesisError(error);
1043
+ return failure(synthesisError);
590
1044
  }
591
1045
  }
592
1046
  async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
593
- const validationOptions = options.validation ?? options;
1047
+ const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
1048
+ if (options.signal?.aborted) {
1049
+ const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
1050
+ return failure(error);
1051
+ }
594
1052
  const pending = (index, status, error) => {
595
1053
  options.onProgress?.({
596
1054
  currentChunk: status === "success" ? index + 1 : index,
@@ -607,77 +1065,175 @@ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
607
1065
  pending(index, "pending");
608
1066
  });
609
1067
  const validations = await Promise.all(
610
- chunks.map(async (chunk) => {
1068
+ chunks.map(async (chunk, index) => {
611
1069
  const ssml = typeof chunk === "string" ? chunk : chunk.ssml;
612
- const diagnostics = await Promise.resolve((0, import_ssml_core.validateAzureSsml)(ssml, validationOptions));
1070
+ const sourceNodePath = typeof chunk === "string" ? options.sourceNodePath : chunk.sourceNodePath ?? options.sourceNodePath;
1071
+ const diagnostics = await Promise.resolve(
1072
+ (0, import_ssml_core2.validateAzureSsml)(ssml, {
1073
+ ...validationOptions,
1074
+ ...sourceNodePath ? { sourceNodePath } : {},
1075
+ chunkIndex: index
1076
+ })
1077
+ );
613
1078
  return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
614
1079
  })
615
1080
  );
616
1081
  const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
1082
+ if (options.signal?.aborted) {
1083
+ const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
1084
+ return failure(error);
1085
+ }
617
1086
  if (firstInvalidIndex >= 0) {
618
1087
  const error = new ChunkValidationError(firstInvalidIndex, validations[firstInvalidIndex] ?? []);
619
1088
  pending(firstInvalidIndex, "failed", error);
620
- return { ok: false, success: false, status: "validation-error", error };
1089
+ return failure(error);
621
1090
  }
622
1091
  try {
623
1092
  if (client.synthesizeChunks) {
624
- const value = await client.synthesizeChunks(chunks, { onProgress: options.onProgress });
1093
+ const normalizedChunks = chunks.map((chunk) => {
1094
+ if (typeof chunk === "string" || chunk.sourceNodePath || !options.sourceNodePath) return chunk;
1095
+ return { ...chunk, sourceNodePath: [...options.sourceNodePath] };
1096
+ });
1097
+ const value = await client.synthesizeChunks(normalizedChunks, {
1098
+ onProgress: options.onProgress,
1099
+ outputFormat: options.outputFormat,
1100
+ signal: options.signal,
1101
+ timeoutMs: options.timeoutMs,
1102
+ sourceNodePath: options.sourceNodePath,
1103
+ concurrency: options.concurrency,
1104
+ retryOptions: options.retryOptions
1105
+ });
625
1106
  return { ok: true, success: true, status: "success", value };
626
1107
  }
627
- const results = [];
628
- for (const [index, chunk] of chunks.entries()) {
629
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
630
- const sourceNodePath = input.sourceNodePath;
631
- pending(index, "synthesizing");
632
- const startedAt = Date.now();
633
- try {
634
- const result = await client.synthesizeSsml(input.ssml);
635
- results.push({
636
- ...result,
637
- ...input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {},
638
- ...sourceNodePath ? {
639
- boundaries: result.boundaries?.map((event) => ({
640
- ...event,
641
- sourceNodePath: [...sourceNodePath]
642
- })),
643
- visemes: result.visemes?.map((event) => ({ ...event, sourceNodePath: [...sourceNodePath] })),
644
- bookmarks: result.bookmarks?.map((event) => ({ ...event, sourceNodePath: [...sourceNodePath] }))
645
- } : {}
646
- });
647
- options.onProgress?.({
648
- currentChunk: index + 1,
649
- totalChunks: chunks.length,
650
- percent: chunks.length === 0 ? 100 : Math.round((index + 1) / chunks.length * 100),
651
- chunkIndex: index,
652
- originalTextRange: input.originalTextRange,
653
- status: "success",
654
- durationMs: Date.now() - startedAt
655
- });
656
- } catch (error) {
657
- options.onProgress?.({
658
- currentChunk: index,
659
- totalChunks: chunks.length,
660
- percent: chunks.length === 0 ? 100 : Math.round(index / chunks.length * 100),
661
- chunkIndex: index,
662
- originalTextRange: input.originalTextRange,
663
- status: "failed",
664
- durationMs: Date.now() - startedAt,
665
- error
666
- });
667
- throw error;
1108
+ const results = new Array(chunks.length);
1109
+ let completed = 0;
1110
+ let nextIndex = 0;
1111
+ const concurrency = resolveConcurrency2(options.concurrency, chunks.length);
1112
+ const worker = async () => {
1113
+ while (true) {
1114
+ const index = nextIndex++;
1115
+ if (index >= chunks.length) return;
1116
+ const chunk = chunks[index];
1117
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
1118
+ const sourceNodePath = input.sourceNodePath;
1119
+ const originalTextRange = input.originalTextRange;
1120
+ pending(index, "synthesizing");
1121
+ const startedAt = Date.now();
1122
+ try {
1123
+ const result = await retryableSynthesis(
1124
+ () => client.synthesizeSsml(input.ssml, {
1125
+ outputFormat: options.outputFormat,
1126
+ signal: options.signal,
1127
+ timeoutMs: options.timeoutMs,
1128
+ sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
1129
+ }),
1130
+ options.retryOptions,
1131
+ options.signal,
1132
+ (retryAttempt, nextRetryDelayMs) => options.onProgress?.({
1133
+ currentChunk: completed,
1134
+ totalChunks: chunks.length,
1135
+ percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1136
+ chunkIndex: index,
1137
+ originalTextRange: input.originalTextRange,
1138
+ status: "synthesizing",
1139
+ durationMs: Date.now() - startedAt,
1140
+ retryAttempt,
1141
+ nextRetryDelayMs,
1142
+ isRetrying: true
1143
+ })
1144
+ );
1145
+ results[index] = {
1146
+ ...result,
1147
+ ...input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {},
1148
+ ...sourceNodePath ? {
1149
+ boundaries: result.boundaries?.map((event) => ({
1150
+ ...event,
1151
+ sourceNodePath: [...sourceNodePath],
1152
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1153
+ })),
1154
+ visemes: result.visemes?.map((event) => ({
1155
+ ...event,
1156
+ sourceNodePath: [...sourceNodePath],
1157
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1158
+ })),
1159
+ bookmarks: result.bookmarks?.map((event) => ({
1160
+ ...event,
1161
+ sourceNodePath: [...sourceNodePath],
1162
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
1163
+ }))
1164
+ } : {},
1165
+ ...originalTextRange ? {
1166
+ boundaries: result.boundaries?.map((event) => ({
1167
+ ...event,
1168
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1169
+ })),
1170
+ wordBoundary: result.wordBoundary?.map((event) => ({
1171
+ ...event,
1172
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1173
+ })),
1174
+ wordBoundaries: result.wordBoundaries?.map((event) => ({
1175
+ ...event,
1176
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1177
+ })),
1178
+ visemes: result.visemes?.map((event) => ({
1179
+ ...event,
1180
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1181
+ })),
1182
+ bookmarks: result.bookmarks?.map((event) => ({
1183
+ ...event,
1184
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
1185
+ }))
1186
+ } : {}
1187
+ };
1188
+ completed += 1;
1189
+ options.onProgress?.({
1190
+ currentChunk: completed,
1191
+ totalChunks: chunks.length,
1192
+ percent: chunks.length === 0 ? 100 : Math.round(completed / chunks.length * 100),
1193
+ chunkIndex: index,
1194
+ originalTextRange: input.originalTextRange,
1195
+ status: "success",
1196
+ durationMs: Date.now() - startedAt
1197
+ });
1198
+ } catch (error) {
1199
+ options.onProgress?.({
1200
+ currentChunk: completed,
1201
+ totalChunks: chunks.length,
1202
+ percent: chunks.length === 0 ? 100 : Math.round(index / chunks.length * 100),
1203
+ chunkIndex: index,
1204
+ originalTextRange: input.originalTextRange,
1205
+ status: "failed",
1206
+ durationMs: Date.now() - startedAt,
1207
+ error
1208
+ });
1209
+ throw error;
1210
+ }
668
1211
  }
669
- }
1212
+ };
1213
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
1214
+ const orderedResults = results.filter((result) => result !== void 0);
670
1215
  return {
671
1216
  ok: true,
672
1217
  success: true,
673
1218
  status: "success",
674
- value: mergeSynthesisResults(results, options.outputFormat)
1219
+ value: mergeSynthesisResults(orderedResults, {
1220
+ format: options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3",
1221
+ signal: options.signal
1222
+ })
675
1223
  };
676
1224
  } catch (error) {
677
- const azureError = error instanceof AzureTtsError ? error : createSpeechSdkError(error);
678
- return { ok: false, success: false, status: "azure-api-error", error: azureError };
1225
+ const synthesisError = toSynthesisError(error);
1226
+ return failure(synthesisError);
679
1227
  }
680
1228
  }
1229
+ function withValidationSignal(options, signal) {
1230
+ if (!signal) return options;
1231
+ return {
1232
+ ...options,
1233
+ urlValidatorSignal: signal,
1234
+ urlValidation: { ...options.urlValidation ?? {}, signal }
1235
+ };
1236
+ }
681
1237
 
682
1238
  // src/client.ts
683
1239
  var ENDPOINT_TEMPLATE = "https://{region}.tts.speech.microsoft.com/cognitiveservices/v1";
@@ -694,11 +1250,21 @@ var AzureTtsClient = class {
694
1250
  const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs };
695
1251
  return synthesizeSpeech(ssml, config);
696
1252
  }
697
- async synthesizeSsml(ssml) {
1253
+ async synthesizeSsml(ssml, options = {}) {
698
1254
  const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
699
1255
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
700
1256
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
701
- return synthesizeSsml(ssml, { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs });
1257
+ return synthesizeSsml(ssml, {
1258
+ endpoint,
1259
+ region,
1260
+ subscriptionKey,
1261
+ outputFormat: options.outputFormat ?? outputFormat,
1262
+ signal: options.signal ?? signal,
1263
+ timeoutMs: options.timeoutMs ?? timeoutMs,
1264
+ sourceNodePath: options.sourceNodePath,
1265
+ sourceTextSegments: options.sourceTextSegments,
1266
+ sourceMarkers: options.sourceMarkers
1267
+ });
702
1268
  }
703
1269
  async synthesizeChunks(chunks, options = {}) {
704
1270
  const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
@@ -707,10 +1273,13 @@ var AzureTtsClient = class {
707
1273
  endpoint,
708
1274
  region,
709
1275
  subscriptionKey,
710
- outputFormat,
711
- signal,
712
- timeoutMs,
713
- onProgress: options.onProgress ?? __privateGet(this, _options).onProgress
1276
+ outputFormat: options.outputFormat ?? outputFormat,
1277
+ signal: options.signal ?? signal,
1278
+ timeoutMs: options.timeoutMs ?? timeoutMs,
1279
+ sourceNodePath: options.sourceNodePath,
1280
+ onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1281
+ concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1282
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
714
1283
  });
715
1284
  }
716
1285
  async synthesizeSsmlSafe(ssml, options = {}) {
@@ -720,7 +1289,11 @@ var AzureTtsClient = class {
720
1289
  return synthesizeSsmlChunksSafe(this, chunks, {
721
1290
  ...options,
722
1291
  outputFormat: options.outputFormat ?? __privateGet(this, _options).outputFormat,
723
- onProgress: options.onProgress ?? __privateGet(this, _options).onProgress
1292
+ signal: options.signal ?? __privateGet(this, _options).signal,
1293
+ timeoutMs: options.timeoutMs ?? __privateGet(this, _options).timeoutMs,
1294
+ onProgress: options.onProgress ?? __privateGet(this, _options).onProgress,
1295
+ concurrency: options.concurrency ?? __privateGet(this, _options).concurrency,
1296
+ retryOptions: options.retryOptions ?? __privateGet(this, _options).retryOptions
724
1297
  });
725
1298
  }
726
1299
  async synthesizeSsmlChunksSafe(chunks, options = {}) {
@@ -817,16 +1390,23 @@ async function fetchAzureVoiceCatalog(options) {
817
1390
  }
818
1391
  // Annotate the CommonJS export names for ESM import in node:
819
1392
  0 && (module.exports = {
1393
+ AudioFormatMismatchError,
820
1394
  AzureTtsClient,
821
1395
  AzureTtsError,
822
1396
  AzureTtsSdkError,
823
1397
  ChunkValidationError,
1398
+ DEFAULT_OUTPUT_FORMAT,
1399
+ MergeError,
1400
+ SynthesisCancelledError,
1401
+ SynthesisTimeoutError,
824
1402
  UnsupportedMergeFormatError,
825
1403
  canMergeAudioFormat,
826
1404
  fetchAzureVoiceCatalog,
1405
+ inspectAudioSpecification,
827
1406
  mergeAudioBuffers,
828
1407
  mergeSynthesisResults,
829
1408
  resolveMergeAudioFormat,
1409
+ resolveMimeType,
830
1410
  synthesizeSpeech,
831
1411
  synthesizeSsml,
832
1412
  synthesizeSsmlChunks,