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