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