@ssml-builder-js/azure-tts-client 2.15.0 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/synthesis.ts CHANGED
@@ -1,30 +1,42 @@
1
1
  import * as SpeechSDK from "microsoft-cognitiveservices-speech-sdk";
2
2
  import { getSsmlSourceMap } from "@ssml-builder-js/ssml-core";
3
3
  import {
4
+ AzureTtsError,
4
5
  MergeError,
6
+ AudioFormatMismatchError,
5
7
  SynthesisCancelledError,
6
8
  SynthesisTimeoutError,
7
9
  toSynthesisError,
8
10
  UnsupportedMergeFormatError,
11
+ getRetryAfterDelayMs,
9
12
  } from "./errors.ts";
10
- import { resolveMimeType, type AzureTtsOutputFormat } from "./outputFormats.ts";
13
+ import { DEFAULT_OUTPUT_FORMAT, resolveMimeType, type AzureTtsOutputFormat } from "./outputFormats.ts";
11
14
  import { createSpeechConfig } from "./speechConfig.ts";
12
15
  import type {
13
16
  MergedSynthesisResult,
14
17
  SsmlSynthesisChunk,
15
18
  SsmlSynthesisResult,
19
+ AudioSpecification,
16
20
  SynthesisProgressEvent,
17
21
  TtsConfig,
22
+ RetryOptions,
23
+ CustomAudioMerger,
24
+ PostMergeValidator,
18
25
  } from "./types.ts";
19
26
 
20
27
  export type MergeAudioFormat = "wav" | "mp3" | "raw";
21
28
 
22
29
  export interface MergeAudioOptions {
23
30
  format: AzureTtsOutputFormat;
31
+ signal?: AbortSignal;
32
+ outputMimeType?: string;
24
33
  }
25
34
 
35
+ export type InputAudioSpecs = AudioSpecification[];
36
+
26
37
  export interface MergeSynthesisOptions extends MergeAudioOptions {
27
- customMerger?: (buffers: ArrayBuffer[], format: string) => Promise<ArrayBuffer> | ArrayBuffer;
38
+ customMerger?: CustomAudioMerger;
39
+ postMergeValidator?: PostMergeValidator;
28
40
  }
29
41
 
30
42
  type AsyncMergeSynthesisOptions = MergeSynthesisOptions & {
@@ -84,6 +96,150 @@ function parseWav(buffer: ArrayBuffer): ParsedWav {
84
96
  return { chunks, data, format };
85
97
  }
86
98
 
99
+ function formatNumber(format: string, pattern: RegExp, fallback: number): number {
100
+ const match = pattern.exec(format);
101
+ return match?.[1] ? Number(match[1]) : fallback;
102
+ }
103
+
104
+ function formatChannels(format: string, fallback: number): number {
105
+ if (/stereo|2ch|dual/i.test(format)) return 2;
106
+ if (/mono|1ch/i.test(format)) return 1;
107
+ return fallback;
108
+ }
109
+
110
+ function formatAudioSpecification(format: string): AudioSpecification {
111
+ const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
112
+ const channels = formatChannels(format, 0);
113
+ const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
114
+ const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1000 : undefined;
115
+ const codec: AudioSpecification["codec"] = /mp3|mpeg/i.test(format)
116
+ ? "mp3"
117
+ : /opus/i.test(format)
118
+ ? "opus"
119
+ : /silk/i.test(format)
120
+ ? "silk"
121
+ : /pcm|mulaw|alaw|siren/i.test(format)
122
+ ? "pcm"
123
+ : "unknown";
124
+ const bitDepthMatch = /(\d+)bit/i.exec(format);
125
+ const container = /(?:wav|wave|riff)/i.test(format)
126
+ ? "riff-wave"
127
+ : /mp3|mpeg/i.test(format)
128
+ ? "mp3-raw"
129
+ : /ogg/i.test(format)
130
+ ? "ogg"
131
+ : /webm/i.test(format)
132
+ ? "webm"
133
+ : /raw/i.test(format)
134
+ ? "raw"
135
+ : undefined;
136
+ return {
137
+ format,
138
+ mimeType: resolveMimeType(format),
139
+ codec,
140
+ sampleRate,
141
+ channels,
142
+ ...(bitrate ? { bitrate } : {}),
143
+ ...(bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {}),
144
+ ...(container ? { container } : {}),
145
+ isVbr: /vbr/i.test(format),
146
+ isCompressed: codec === "mp3" || codec === "opus" || codec === "silk",
147
+ };
148
+ }
149
+
150
+ function parseMp3Specification(buffer: ArrayBuffer, format: string): AudioSpecification | undefined {
151
+ const bytes = stripMp3Tags(buffer);
152
+ const bitrates = [
153
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
154
+ [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0],
155
+ [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0],
156
+ ];
157
+ const sampleRates = [
158
+ [44_100, 48_000, 32_000],
159
+ [22_050, 24_000, 16_000],
160
+ [11_025, 12_000, 8_000],
161
+ ];
162
+ for (let index = 0; index + 4 <= bytes.length; index += 1) {
163
+ if (bytes[index] !== 0xff || (bytes[index + 1] ?? 0) < 0xe0) continue;
164
+ const header = bytes[index + 1] ?? 0;
165
+ const versionBits = (header >> 3) & 0x03;
166
+ const layer = (header >> 1) & 0x03;
167
+ const bitrateIndex = (bytes[index + 2] ?? 0) >> 4;
168
+ const sampleIndex = ((bytes[index + 2] ?? 0) >> 2) & 0x03;
169
+ if (versionBits === 1 || layer !== 1 || bitrateIndex === 0 || bitrateIndex === 15 || sampleIndex === 3) continue;
170
+ const versionIndex = versionBits === 3 ? 0 : versionBits === 2 ? 1 : 2;
171
+ const bitrateTable = versionBits === 3 ? bitrates[1] : bitrates[2];
172
+ const sampleRate = sampleRates[versionIndex]?.[sampleIndex] ?? 0;
173
+ const bitrateKbps = bitrateTable?.[bitrateIndex] ?? 0;
174
+ if (!sampleRate || !bitrateKbps) continue;
175
+ return {
176
+ format,
177
+ mimeType: "audio/mpeg",
178
+ codec: "mp3",
179
+ sampleRate,
180
+ channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
181
+ bitrate: bitrateKbps * 1000,
182
+ container: "mp3-raw",
183
+ isVbr: false,
184
+ isCompressed: true,
185
+ };
186
+ }
187
+ return undefined;
188
+ }
189
+
190
+ /** Extracts the stream specification from a WAV/MP3 header and output-format fallback. */
191
+ export function inspectAudioSpecification(buffer: ArrayBuffer, format: string): AudioSpecification {
192
+ if (isWavFormat(format) || ascii(new Uint8Array(buffer), 0, "RIFF")) {
193
+ const parsed = parseWav(buffer);
194
+ if (parsed.format.byteLength < 16) throw new Error("Invalid WAV fmt chunk.");
195
+ const view = new DataView(parsed.format.buffer, parsed.format.byteOffset, parsed.format.byteLength);
196
+ const sampleRate = view.getUint32(4, true);
197
+ const channels = view.getUint16(2, true);
198
+ const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
199
+ const formatCode = view.getUint16(0, true);
200
+ return {
201
+ format,
202
+ mimeType: "audio/wav",
203
+ codec: formatCode === 1 ? "pcm" : "unknown",
204
+ sampleRate,
205
+ channels,
206
+ ...(sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {}),
207
+ bitDepth: bitsPerSample,
208
+ container: "riff-wave",
209
+ isVbr: false,
210
+ isCompressed: formatCode !== 1,
211
+ };
212
+ }
213
+ if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
214
+ return formatAudioSpecification(format);
215
+ }
216
+
217
+ function validateAudioSpecifications(specs: readonly AudioSpecification[]): void {
218
+ const first = specs[0];
219
+ if (!first) return;
220
+ const mismatch = specs.find(
221
+ (spec) =>
222
+ spec.sampleRate !== first.sampleRate ||
223
+ spec.channels !== first.channels ||
224
+ (first.bitrate !== undefined && spec.bitrate !== undefined && spec.bitrate !== first.bitrate) ||
225
+ (first.bitDepth !== undefined && spec.bitDepth !== undefined && spec.bitDepth !== first.bitDepth) ||
226
+ (first.container !== undefined && spec.container !== undefined && spec.container !== first.container) ||
227
+ (first.isVbr !== undefined && spec.isVbr !== undefined && spec.isVbr !== first.isVbr),
228
+ );
229
+ if (mismatch)
230
+ throw new AudioFormatMismatchError(
231
+ `Audio chunks have incompatible specifications: ${first.sampleRate}Hz/${first.channels}ch versus ${mismatch.sampleRate}Hz/${mismatch.channels}ch.`,
232
+ specs,
233
+ );
234
+ }
235
+
236
+ function isAudioFormatMismatch(error: unknown): error is AudioFormatMismatchError {
237
+ return (
238
+ error instanceof AudioFormatMismatchError ||
239
+ (error !== null && typeof error === "object" && "kind" in error && error.kind === "audio-format-mismatch")
240
+ );
241
+ }
242
+
87
243
  function writeUint32(target: Uint8Array, offset: number, value: number): void {
88
244
  new DataView(target.buffer).setUint32(offset, value, true);
89
245
  }
@@ -185,6 +341,7 @@ export function mergeAudioBuffers(buffers: readonly ArrayBuffer[], options: Merg
185
341
  const format = typeof options === "string" ? options : options?.format;
186
342
  if (!format) throw new UnsupportedMergeFormatError("");
187
343
  try {
344
+ validateAudioSpecifications(buffers.map((buffer) => inspectAudioSpecification(buffer, format)));
188
345
  if (isWavFormat(format)) return mergeWavBuffers(buffers);
189
346
  if (isMp3Format(format)) {
190
347
  const parts = buffers.map(stripMp3Tags);
@@ -207,7 +364,8 @@ export function mergeAudioBuffers(buffers: readonly ArrayBuffer[], options: Merg
207
364
  }
208
365
  throw new UnsupportedMergeFormatError(format);
209
366
  } catch (error) {
210
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
367
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
368
+ throw error;
211
369
  throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
212
370
  }
213
371
  }
@@ -298,6 +456,7 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
298
456
  textRange?: { start: number; end: number };
299
457
  originalTextRange?: { start: number; end: number };
300
458
  sourceNodePath?: string[];
459
+ mappingStatus: "exact" | "fallback" | "unmapped";
301
460
  } => {
302
461
  const marker = markerName ? sourceMarkers.find((candidate) => candidate.name === markerName) : undefined;
303
462
  if (marker) {
@@ -305,16 +464,23 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
305
464
  originalTextRange: { ...marker.originalTextRange },
306
465
  sourceNodePath: [...marker.sourceNodePath],
307
466
  textRange: { ...marker.originalTextRange },
467
+ mappingStatus: "exact",
308
468
  };
309
469
  }
310
- if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) return {};
470
+ if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) {
471
+ return { mappingStatus: "unmapped" };
472
+ }
311
473
  const value = text ?? "";
312
474
  let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? (offsetHint as number) : -1;
313
- if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value)
475
+ let mappingStatus: "exact" | "fallback" | "unmapped" = "exact";
476
+ if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value) {
314
477
  localStart = -1;
478
+ mappingStatus = "fallback";
479
+ }
315
480
  if (localStart < 0 || localStart > sourceText.length) {
316
481
  localStart = value ? sourceText.indexOf(value, sourceEventCursor) : sourceEventCursor;
317
482
  if (localStart < 0) localStart = value ? sourceText.indexOf(value) : sourceEventCursor;
483
+ mappingStatus = "fallback";
318
484
  }
319
485
  localStart = Math.max(0, localStart);
320
486
  const localEnd = Math.min(sourceText.length, localStart + value.length);
@@ -335,6 +501,7 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
335
501
  : config.sourceNodePath
336
502
  ? { sourceNodePath: [...config.sourceNodePath] }
337
503
  : {}),
504
+ mappingStatus: segment || config.sourceTextRange || config.sourceNodePath ? mappingStatus : "unmapped",
338
505
  };
339
506
  };
340
507
  synthesizer.wordBoundary = (_sender, event) => {
@@ -384,22 +551,36 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
384
551
  );
385
552
  const durationMs = result.audioDuration ? ticksToMilliseconds(result.audioDuration) : eventDurationMs;
386
553
  const requestId = (result as SpeechSDK.SpeechSynthesisResult & { resultId?: string }).resultId;
387
- const addSourceMetadata = <T extends { audioOffsetMs: number }>(event: T): T => ({
388
- ...event,
389
- ...(config.sourceTextRange && !("textRange" in event) ? { textRange: { ...config.sourceTextRange } } : {}),
390
- ...(config.sourceTextRange && !("originalTextRange" in event)
391
- ? { originalTextRange: { ...config.sourceTextRange } }
392
- : {}),
393
- ...(config.chunkIndex !== undefined ? { chunkIndex: config.chunkIndex } : {}),
394
- ...(config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {}),
395
- ...(requestId ? { requestId } : {}),
396
- });
554
+ const addSourceMetadata = <T extends { audioOffsetMs: number; mappingStatus: "exact" | "fallback" | "unmapped" }>(
555
+ event: T,
556
+ ): T => {
557
+ const mapped = {
558
+ ...event,
559
+ ...(config.sourceTextRange && !("textRange" in event) ? { textRange: { ...config.sourceTextRange } } : {}),
560
+ ...(config.sourceTextRange && !("originalTextRange" in event)
561
+ ? { originalTextRange: { ...config.sourceTextRange } }
562
+ : {}),
563
+ ...(config.chunkIndex !== undefined ? { chunkIndex: config.chunkIndex } : {}),
564
+ ...(config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {}),
565
+ ...(requestId ? { requestId } : {}),
566
+ } as T;
567
+ if (event.mappingStatus === "unmapped") {
568
+ Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
569
+ Object.defineProperty(mapped, "toJSON", {
570
+ value: () => ({ ...mapped, mappingStatus: "unmapped" }),
571
+ enumerable: false,
572
+ });
573
+ }
574
+ return mapped;
575
+ };
397
576
  const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
398
577
  const sourceVisemes = visemes.map((viseme) => addSourceMetadata(viseme));
399
578
  const sourceBookmarks = bookmarks.map((bookmark) => addSourceMetadata(bookmark));
400
579
  resolve({
401
580
  audioData: result.audioData,
402
581
  durationMs,
582
+ audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
583
+ mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
403
584
  ...(config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {}),
404
585
  ...(requestId ? { requestId } : {}),
405
586
  ...(sourceBoundaries.length > 0
@@ -415,10 +596,11 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
415
596
  abortHandler = () => rejectWithError(new SynthesisCancelledError());
416
597
  config.signal.addEventListener("abort", abortHandler, { once: true });
417
598
  }
418
- if (config.timeoutMs !== undefined && config.timeoutMs > 0) {
599
+ const timeoutMs = config.timeouts?.perChunkMs ?? config.timeoutMs;
600
+ if (timeoutMs !== undefined && timeoutMs > 0) {
419
601
  timeout = setTimeout(
420
- () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeoutMs} ms.`)),
421
- config.timeoutMs,
602
+ () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
603
+ timeoutMs,
422
604
  );
423
605
  }
424
606
  synthesizer.speakSsmlAsync(ssml, cb, rejectWithError);
@@ -428,13 +610,150 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
428
610
  });
429
611
  }
430
612
 
431
- /** Synthesizes chunks sequentially, annotates synchronization events, and merges the results. */
613
+ function isRetryableSynthesisError(error: unknown): boolean {
614
+ if (error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError) return false;
615
+ if (error instanceof AzureTtsError && error.status !== 0)
616
+ return error.status === 429 || (error.status >= 500 && error.status < 600);
617
+ const message = error instanceof Error ? error.message : String(error);
618
+ if (/\b4\d{2}\b/.test(message)) return false;
619
+ const status = error && typeof error === "object" && "status" in error ? error.status : undefined;
620
+ if (typeof status === "number") return status === 429 || (status >= 500 && status < 600);
621
+ return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
622
+ }
623
+
624
+ function retryDelay(options: RetryOptions, retryAttempt: number, error?: unknown): number {
625
+ const retryAfterMs = getRetryAfterDelayMs(error);
626
+ if (retryAfterMs !== undefined) return retryAfterMs;
627
+ const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
628
+ return Math.floor(Math.random() * (base + 1));
629
+ }
630
+
631
+ function resolveConcurrency(value: number | undefined, total: number): number {
632
+ if (value === undefined) return 1;
633
+ if (value === Infinity) return Math.max(1, total);
634
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
635
+ }
636
+
637
+ async function waitForRetry(delayMs: number, signal?: AbortSignal): Promise<void> {
638
+ if (signal?.aborted) throw new SynthesisCancelledError();
639
+ if (delayMs <= 0) return;
640
+ await new Promise<void>((resolve, reject) => {
641
+ let timer: ReturnType<typeof setTimeout>;
642
+ const abort = () => {
643
+ clearTimeout(timer);
644
+ signal?.removeEventListener("abort", abort);
645
+ reject(new SynthesisCancelledError());
646
+ };
647
+ timer = setTimeout(() => {
648
+ signal?.removeEventListener("abort", abort);
649
+ resolve();
650
+ }, delayMs);
651
+ if (signal) {
652
+ signal.addEventListener("abort", abort, { once: true });
653
+ }
654
+ });
655
+ }
656
+
657
+ async function synthesizeWithRetry(
658
+ ssml: string,
659
+ config: TtsConfig,
660
+ retryOptions: RetryOptions | undefined,
661
+ onRetry: (retryAttempt: number, nextRetryDelayMs: number) => void,
662
+ ): Promise<SsmlSynthesisResult> {
663
+ const options = retryOptions
664
+ ? {
665
+ maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
666
+ initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
667
+ maxDelayMs: Math.max(0, retryOptions.maxDelayMs),
668
+ shouldRetry: retryOptions.shouldRetry,
669
+ }
670
+ : undefined;
671
+ let attempt = 0;
672
+ while (true) {
673
+ if (config.signal?.aborted) throw new SynthesisCancelledError();
674
+ try {
675
+ return await synthesizeSsml(ssml, config);
676
+ } catch (error) {
677
+ if (
678
+ !options ||
679
+ attempt >= options.maxRetries ||
680
+ !(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error))
681
+ )
682
+ throw error;
683
+ attempt += 1;
684
+ const delayMs = retryDelay(options, attempt, error);
685
+ onRetry(attempt, delayMs);
686
+ await waitForRetry(delayMs, config.signal);
687
+ }
688
+ }
689
+ }
690
+
691
+ interface AbortScope {
692
+ signal: AbortSignal;
693
+ timedOut: () => boolean;
694
+ dispose: () => void;
695
+ abort: () => void;
696
+ }
697
+
698
+ function createAbortScope(parent: AbortSignal | undefined, timeoutMs: number | undefined): AbortScope {
699
+ const controller = new AbortController();
700
+ let didTimeout = false;
701
+ const onAbort = () => controller.abort();
702
+ if (parent?.aborted) controller.abort();
703
+ parent?.addEventListener("abort", onAbort, { once: true });
704
+ const timer =
705
+ timeoutMs !== undefined && timeoutMs > 0
706
+ ? setTimeout(() => {
707
+ didTimeout = true;
708
+ controller.abort();
709
+ }, timeoutMs)
710
+ : undefined;
711
+ return {
712
+ signal: controller.signal,
713
+ timedOut: () => didTimeout,
714
+ dispose: () => {
715
+ if (timer) clearTimeout(timer);
716
+ parent?.removeEventListener("abort", onAbort);
717
+ },
718
+ abort: () => controller.abort(),
719
+ };
720
+ }
721
+
722
+ async function synthesizeChunkWithTimeout(
723
+ ssml: string,
724
+ config: TtsConfig,
725
+ retryOptions: RetryOptions | undefined,
726
+ timeoutMs: number | undefined,
727
+ onRetry: (retryAttempt: number, nextRetryDelayMs: number) => void,
728
+ ): Promise<SsmlSynthesisResult> {
729
+ const scope = createAbortScope(config.signal, timeoutMs);
730
+ try {
731
+ return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry);
732
+ } catch (error) {
733
+ if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
734
+ throw error;
735
+ } finally {
736
+ scope.dispose();
737
+ }
738
+ }
739
+
740
+ /** Synthesizes chunks with bounded concurrency, retries transient failures, and merges in chunk order. */
432
741
  export async function synthesizeSsmlChunks(
433
742
  chunks: readonly (SsmlSynthesisChunk | string)[],
434
743
  config: TtsConfig,
435
744
  ): Promise<SsmlSynthesisResult> {
436
- const results: SsmlSynthesisResult[] = [];
745
+ const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
437
746
  const totalChunks = chunks.length;
747
+ const cachedChunks = new Map((config.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
748
+ for (const [index, cached] of cachedChunks) {
749
+ if (index >= 0 && index < totalChunks) results[index] = cached;
750
+ }
751
+ const requestedIndices = config.resumeChunkIndices
752
+ ? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks))
753
+ : undefined;
754
+ const shouldSynthesize = (index: number): boolean =>
755
+ !cachedChunks.has(index) && (requestedIndices === undefined || requestedIndices.has(index));
756
+ const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
438
757
  const report = (event: SynthesisProgressEvent): void => config.onProgress?.(event);
439
758
  for (const [index, chunk] of chunks.entries()) {
440
759
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
@@ -448,57 +767,115 @@ export async function synthesizeSsmlChunks(
448
767
  durationMs: 0,
449
768
  });
450
769
  }
451
- for (const [index, chunk] of chunks.entries()) {
452
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
453
- report({
454
- currentChunk: index,
455
- totalChunks,
456
- percent: totalChunks === 0 ? 100 : Math.round((index / totalChunks) * 100),
457
- chunkIndex: index,
458
- originalTextRange: input.originalTextRange,
459
- status: "synthesizing",
460
- durationMs: 0,
461
- });
462
- const startedAt = Date.now();
463
- try {
464
- const result = await synthesizeSsml(input.ssml, {
465
- ...config,
466
- ...(input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {}),
467
- ...((input.sourceNodePath ?? config.sourceNodePath)
468
- ? { sourceNodePath: [...(input.sourceNodePath ?? config.sourceNodePath ?? [])] }
469
- : {}),
470
- ...(input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {}),
471
- ...(input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {}),
472
- chunkIndex: index,
473
- onProgress: undefined,
474
- });
475
- results.push(result);
770
+ let completed = [...results].filter((result) => result !== undefined).length;
771
+ let nextIndex = 0;
772
+ const concurrency = resolveConcurrency(config.concurrency, chunks.length);
773
+ let firstError: unknown;
774
+ const failedIndices = new Set<number>();
775
+ const worker = async (): Promise<void> => {
776
+ while (true) {
777
+ const index = nextIndex++;
778
+ if (index >= chunks.length) return;
779
+ if (!shouldSynthesize(index)) continue;
780
+ if (firstError && config.cancelOnFailure !== false) return;
781
+ const chunk = chunks[index];
782
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
476
783
  report({
477
- currentChunk: index + 1,
784
+ currentChunk: completed,
478
785
  totalChunks,
479
- percent: totalChunks === 0 ? 100 : Math.round(((index + 1) / totalChunks) * 100),
786
+ percent: totalChunks === 0 ? 100 : Math.round((completed / totalChunks) * 100),
480
787
  chunkIndex: index,
481
788
  originalTextRange: input.originalTextRange,
482
- status: "success",
483
- durationMs: Date.now() - startedAt,
789
+ status: "synthesizing",
790
+ durationMs: 0,
484
791
  });
485
- } catch (error) {
486
- report({
487
- currentChunk: index,
488
- totalChunks,
489
- percent: totalChunks === 0 ? 100 : Math.round((index / totalChunks) * 100),
490
- chunkIndex: index,
491
- originalTextRange: input.originalTextRange,
492
- status: "failed",
493
- durationMs: Date.now() - startedAt,
494
- error,
495
- });
496
- throw error;
792
+ const startedAt = Date.now();
793
+ try {
794
+ const result = await synthesizeChunkWithTimeout(
795
+ input.ssml,
796
+ {
797
+ ...config,
798
+ signal: scope.signal,
799
+ ...(input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {}),
800
+ ...((input.sourceNodePath ?? config.sourceNodePath)
801
+ ? { sourceNodePath: [...(input.sourceNodePath ?? config.sourceNodePath ?? [])] }
802
+ : {}),
803
+ ...(input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {}),
804
+ ...(input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {}),
805
+ chunkIndex: index,
806
+ onProgress: undefined,
807
+ },
808
+ config.retryOptions,
809
+ config.timeouts?.chunkWithRetriesMs ?? config.timeouts?.perChunkMs ?? config.timeoutMs,
810
+ (retryAttempt, nextRetryDelayMs) =>
811
+ report({
812
+ currentChunk: completed,
813
+ totalChunks,
814
+ percent: totalChunks === 0 ? 100 : Math.round((completed / totalChunks) * 100),
815
+ chunkIndex: index,
816
+ originalTextRange: input.originalTextRange,
817
+ status: "synthesizing",
818
+ durationMs: Date.now() - startedAt,
819
+ retryAttempt,
820
+ nextRetryDelayMs,
821
+ isRetrying: true,
822
+ }),
823
+ );
824
+ results[index] = result;
825
+ completed += 1;
826
+ report({
827
+ currentChunk: completed,
828
+ totalChunks,
829
+ percent: totalChunks === 0 ? 100 : Math.round((completed / totalChunks) * 100),
830
+ chunkIndex: index,
831
+ originalTextRange: input.originalTextRange,
832
+ status: "success",
833
+ durationMs: Date.now() - startedAt,
834
+ });
835
+ } catch (error) {
836
+ failedIndices.add(index);
837
+ report({
838
+ currentChunk: completed,
839
+ totalChunks,
840
+ percent: totalChunks === 0 ? 100 : Math.round((completed / totalChunks) * 100),
841
+ chunkIndex: index,
842
+ originalTextRange: input.originalTextRange,
843
+ status: "failed",
844
+ durationMs: Date.now() - startedAt,
845
+ error,
846
+ });
847
+ firstError ??= scope.timedOut()
848
+ ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`)
849
+ : error;
850
+ if (config.cancelOnFailure !== false) scope.abort();
851
+ return;
852
+ }
497
853
  }
854
+ };
855
+ try {
856
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
857
+ if (firstError) throw firstError;
858
+ const orderedResults = results.filter((result): result is SsmlSynthesisResult => result !== undefined);
859
+ return await mergeSynthesisResults(orderedResults, {
860
+ format: (config.outputFormat ?? DEFAULT_OUTPUT_FORMAT) as AzureTtsOutputFormat,
861
+ signal: scope.signal,
862
+ customMerger: config.customMerger,
863
+ outputMimeType: config.outputMimeType,
864
+ postMergeValidator: config.postMergeValidator,
865
+ });
866
+ } catch (error) {
867
+ const partial = {
868
+ synthesizedChunks: results.flatMap((result, chunkIndex) => (result ? [{ ...result, chunkIndex }] : [])),
869
+ completedChunks: results.flatMap((result, chunkIndex) => (result ? [{ ...result, chunkIndex }] : [])),
870
+ pendingChunkIndices: chunks.flatMap((_chunk, chunkIndex) => (results[chunkIndex] ? [] : [chunkIndex])),
871
+ failedChunkIndices: [...failedIndices],
872
+ totalChunks,
873
+ };
874
+ if (error && typeof error === "object") (error as { partialResult?: unknown }).partialResult = partial;
875
+ throw error;
876
+ } finally {
877
+ scope.dispose();
498
878
  }
499
- return mergeSynthesisResults(results, {
500
- format: (config.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3") as AzureTtsOutputFormat,
501
- });
502
879
  }
503
880
 
504
881
  /** Concatenates audio buffers and shifts all synchronization events by prior chunk durations. */
@@ -506,6 +883,8 @@ function createMergedResult(
506
883
  results: readonly SsmlSynthesisResult[],
507
884
  audioData: ArrayBuffer,
508
885
  format: string,
886
+ audioSpec?: AudioSpecification,
887
+ outputMimeType?: string,
509
888
  ): MergedSynthesisResult {
510
889
  const boundaries: NonNullable<SsmlSynthesisResult["boundaries"]> = [];
511
890
  const visemes: NonNullable<SsmlSynthesisResult["visemes"]> = [];
@@ -530,6 +909,7 @@ function createMergedResult(
530
909
  ...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
531
910
  ...(textRange ? { textRange: { ...textRange } } : {}),
532
911
  ...(requestId ? { requestId } : {}),
912
+ mappingStatus: boundary.mappingStatus ?? "unmapped",
533
913
  });
534
914
  }
535
915
  for (const viseme of result.visemes ?? []) {
@@ -545,6 +925,7 @@ function createMergedResult(
545
925
  ...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
546
926
  ...(textRange ? { textRange: { ...textRange } } : {}),
547
927
  ...(requestId ? { requestId } : {}),
928
+ mappingStatus: viseme.mappingStatus ?? "unmapped",
548
929
  });
549
930
  }
550
931
  for (const bookmark of result.bookmarks ?? []) {
@@ -560,6 +941,7 @@ function createMergedResult(
560
941
  ...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
561
942
  ...(textRange ? { textRange: { ...textRange } } : {}),
562
943
  ...(requestId ? { requestId } : {}),
944
+ mappingStatus: bookmark.mappingStatus ?? "unmapped",
563
945
  });
564
946
  }
565
947
  durationOffset += Math.max(0, result.durationMs);
@@ -569,6 +951,8 @@ function createMergedResult(
569
951
  audioData,
570
952
  durationMs: durationOffset,
571
953
  mimeType: resolveMimeType(format),
954
+ audioSpec: audioSpec ?? formatAudioSpecification(format),
955
+ ...(outputMimeType ? { mimeType: outputMimeType } : {}),
572
956
  ...(boundaries.length > 0 ? { boundaries, wordBoundary: boundaries, wordBoundaries: boundaries } : {}),
573
957
  ...(visemes.length > 0 ? { visemes } : {}),
574
958
  ...(bookmarks.length > 0 ? { bookmarks } : {}),
@@ -581,6 +965,10 @@ export function mergeSynthesisResults(
581
965
  results: readonly SsmlSynthesisResult[],
582
966
  options: AsyncMergeSynthesisOptions,
583
967
  ): Promise<MergedSynthesisResult>;
968
+ export function mergeSynthesisResults(
969
+ results: readonly SsmlSynthesisResult[],
970
+ options: MergeSynthesisOptions,
971
+ ): MergedSynthesisResult | Promise<MergedSynthesisResult>;
584
972
  export function mergeSynthesisResults(
585
973
  results: readonly SsmlSynthesisResult[],
586
974
  options: MergeAudioOptions,
@@ -594,22 +982,79 @@ export function mergeSynthesisResults(
594
982
  const format = resolvedOptions?.format;
595
983
  if (!format) throw new UnsupportedMergeFormatError("");
596
984
  const buffers = results.map((result) => result.audioData);
985
+ const inputSpecs = results.map((result) => result.audioSpec ?? inspectAudioSpecification(result.audioData, format));
986
+ validateAudioSpecifications(inputSpecs);
987
+ const signal = resolvedOptions.signal ?? new AbortController().signal;
988
+ if (signal.aborted) throw new SynthesisCancelledError();
597
989
  if (resolvedOptions.customMerger) {
598
990
  return Promise.resolve()
599
- .then(() => resolvedOptions.customMerger?.(buffers, format))
991
+ .then(() =>
992
+ resolvedOptions.customMerger?.(buffers, {
993
+ format,
994
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
995
+ inputSpecs,
996
+ signal,
997
+ }),
998
+ )
600
999
  .then((merged) => {
601
1000
  if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
602
- return createMergedResult(results, merged, format);
1001
+ if (
1002
+ !(merged instanceof ArrayBuffer) ||
1003
+ (buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
1004
+ )
1005
+ throw new MergeError("The custom audio merger returned an invalid audio buffer.");
1006
+ if (signal.aborted) throw new SynthesisCancelledError();
1007
+ const result = createMergedResult(
1008
+ results,
1009
+ merged,
1010
+ format,
1011
+ inspectAudioSpecification(merged, format),
1012
+ resolvedOptions.outputMimeType,
1013
+ );
1014
+ return Promise.resolve(
1015
+ resolvedOptions.postMergeValidator?.(result, {
1016
+ format,
1017
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
1018
+ inputSpecs,
1019
+ signal,
1020
+ }),
1021
+ ).then((valid) => {
1022
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1023
+ return result;
1024
+ });
603
1025
  })
604
1026
  .catch((error: unknown) => {
605
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
1027
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
1028
+ throw error;
606
1029
  throw new MergeError(`Custom audio merger failed for format "${format}".`, error);
607
1030
  });
608
1031
  }
609
1032
  try {
610
- return createMergedResult(results, mergeAudioBuffers(buffers, { format }), format);
1033
+ const result = createMergedResult(
1034
+ results,
1035
+ mergeAudioBuffers(buffers, { format }),
1036
+ format,
1037
+ inputSpecs[0],
1038
+ resolvedOptions.outputMimeType,
1039
+ );
1040
+ if (resolvedOptions.postMergeValidator) {
1041
+ const validation = resolvedOptions.postMergeValidator(result, {
1042
+ format,
1043
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
1044
+ inputSpecs,
1045
+ signal,
1046
+ });
1047
+ if (validation instanceof Promise)
1048
+ return validation.then((valid) => {
1049
+ if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1050
+ return result;
1051
+ });
1052
+ if (validation === false) throw new MergeError("The post-merge validator rejected the merged audio.");
1053
+ }
1054
+ return result;
611
1055
  } catch (error) {
612
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
1056
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
1057
+ throw error;
613
1058
  throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
614
1059
  }
615
1060
  }