@ssml-builder-js/azure-tts-client 2.15.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/src/synthesis.ts CHANGED
@@ -1,30 +1,45 @@
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,
9
11
  } from "./errors.ts";
10
- import { resolveMimeType, type AzureTtsOutputFormat } from "./outputFormats.ts";
12
+ import { DEFAULT_OUTPUT_FORMAT, resolveMimeType, type AzureTtsOutputFormat } from "./outputFormats.ts";
11
13
  import { createSpeechConfig } from "./speechConfig.ts";
12
14
  import type {
13
15
  MergedSynthesisResult,
14
16
  SsmlSynthesisChunk,
15
17
  SsmlSynthesisResult,
18
+ AudioSpecification,
16
19
  SynthesisProgressEvent,
17
20
  TtsConfig,
21
+ RetryOptions,
18
22
  } from "./types.ts";
19
23
 
20
24
  export type MergeAudioFormat = "wav" | "mp3" | "raw";
21
25
 
22
26
  export interface MergeAudioOptions {
23
27
  format: AzureTtsOutputFormat;
28
+ signal?: AbortSignal;
29
+ outputMimeType?: string;
30
+ }
31
+
32
+ export type InputAudioSpecs = AudioSpecification[];
33
+
34
+ export interface CustomMergerContext {
35
+ format: string;
36
+ outputMimeType: string;
37
+ inputSpecs: InputAudioSpecs;
38
+ signal: AbortSignal;
24
39
  }
25
40
 
26
41
  export interface MergeSynthesisOptions extends MergeAudioOptions {
27
- customMerger?: (buffers: ArrayBuffer[], format: string) => Promise<ArrayBuffer> | ArrayBuffer;
42
+ customMerger?: (buffers: ArrayBuffer[], context: CustomMergerContext) => Promise<ArrayBuffer> | ArrayBuffer;
28
43
  }
29
44
 
30
45
  type AsyncMergeSynthesisOptions = MergeSynthesisOptions & {
@@ -84,6 +99,127 @@ function parseWav(buffer: ArrayBuffer): ParsedWav {
84
99
  return { chunks, data, format };
85
100
  }
86
101
 
102
+ function formatNumber(format: string, pattern: RegExp, fallback: number): number {
103
+ const match = pattern.exec(format);
104
+ return match?.[1] ? Number(match[1]) : fallback;
105
+ }
106
+
107
+ function formatChannels(format: string, fallback: number): number {
108
+ if (/stereo|2ch|dual/i.test(format)) return 2;
109
+ if (/mono|1ch/i.test(format)) return 1;
110
+ return fallback;
111
+ }
112
+
113
+ function formatAudioSpecification(format: string): AudioSpecification {
114
+ const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
115
+ const channels = formatChannels(format, 0);
116
+ const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
117
+ const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1000 : undefined;
118
+ const codec: AudioSpecification["codec"] = /mp3|mpeg/i.test(format)
119
+ ? "mp3"
120
+ : /opus/i.test(format)
121
+ ? "opus"
122
+ : /silk/i.test(format)
123
+ ? "silk"
124
+ : /pcm|mulaw|alaw|siren/i.test(format)
125
+ ? "pcm"
126
+ : "unknown";
127
+ return {
128
+ format,
129
+ mimeType: resolveMimeType(format),
130
+ codec,
131
+ sampleRate,
132
+ channels,
133
+ ...(bitrate ? { bitrate } : {}),
134
+ isCompressed: codec === "mp3" || codec === "opus" || codec === "silk",
135
+ };
136
+ }
137
+
138
+ function parseMp3Specification(buffer: ArrayBuffer, format: string): AudioSpecification | undefined {
139
+ const bytes = stripMp3Tags(buffer);
140
+ const bitrates = [
141
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
142
+ [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0],
143
+ [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0],
144
+ ];
145
+ const sampleRates = [
146
+ [44_100, 48_000, 32_000],
147
+ [22_050, 24_000, 16_000],
148
+ [11_025, 12_000, 8_000],
149
+ ];
150
+ for (let index = 0; index + 4 <= bytes.length; index += 1) {
151
+ if (bytes[index] !== 0xff || (bytes[index + 1] ?? 0) < 0xe0) continue;
152
+ const header = bytes[index + 1] ?? 0;
153
+ const versionBits = (header >> 3) & 0x03;
154
+ const layer = (header >> 1) & 0x03;
155
+ const bitrateIndex = (bytes[index + 2] ?? 0) >> 4;
156
+ const sampleIndex = ((bytes[index + 2] ?? 0) >> 2) & 0x03;
157
+ if (versionBits === 1 || layer !== 1 || bitrateIndex === 0 || bitrateIndex === 15 || sampleIndex === 3) continue;
158
+ const versionIndex = versionBits === 3 ? 0 : versionBits === 2 ? 1 : 2;
159
+ const bitrateTable = versionBits === 3 ? bitrates[1] : bitrates[2];
160
+ const sampleRate = sampleRates[versionIndex]?.[sampleIndex] ?? 0;
161
+ const bitrateKbps = bitrateTable?.[bitrateIndex] ?? 0;
162
+ if (!sampleRate || !bitrateKbps) continue;
163
+ return {
164
+ format,
165
+ mimeType: "audio/mpeg",
166
+ codec: "mp3",
167
+ sampleRate,
168
+ channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
169
+ bitrate: bitrateKbps * 1000,
170
+ isCompressed: true,
171
+ };
172
+ }
173
+ return undefined;
174
+ }
175
+
176
+ /** Extracts the stream specification from a WAV/MP3 header and output-format fallback. */
177
+ export function inspectAudioSpecification(buffer: ArrayBuffer, format: string): AudioSpecification {
178
+ if (isWavFormat(format) || ascii(new Uint8Array(buffer), 0, "RIFF")) {
179
+ const parsed = parseWav(buffer);
180
+ if (parsed.format.byteLength < 16) throw new Error("Invalid WAV fmt chunk.");
181
+ const view = new DataView(parsed.format.buffer, parsed.format.byteOffset, parsed.format.byteLength);
182
+ const sampleRate = view.getUint32(4, true);
183
+ const channels = view.getUint16(2, true);
184
+ const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
185
+ const formatCode = view.getUint16(0, true);
186
+ return {
187
+ format,
188
+ mimeType: "audio/wav",
189
+ codec: formatCode === 1 ? "pcm" : "unknown",
190
+ sampleRate,
191
+ channels,
192
+ ...(sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {}),
193
+ isCompressed: formatCode !== 1,
194
+ };
195
+ }
196
+ if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
197
+ return formatAudioSpecification(format);
198
+ }
199
+
200
+ function validateAudioSpecifications(specs: readonly AudioSpecification[]): void {
201
+ const first = specs[0];
202
+ if (!first) return;
203
+ const mismatch = specs.find(
204
+ (spec) =>
205
+ spec.sampleRate !== first.sampleRate ||
206
+ spec.channels !== first.channels ||
207
+ (first.bitrate !== undefined && spec.bitrate !== undefined && spec.bitrate !== first.bitrate),
208
+ );
209
+ if (mismatch)
210
+ throw new AudioFormatMismatchError(
211
+ `Audio chunks have incompatible specifications: ${first.sampleRate}Hz/${first.channels}ch versus ${mismatch.sampleRate}Hz/${mismatch.channels}ch.`,
212
+ specs,
213
+ );
214
+ }
215
+
216
+ function isAudioFormatMismatch(error: unknown): error is AudioFormatMismatchError {
217
+ return (
218
+ error instanceof AudioFormatMismatchError ||
219
+ (error !== null && typeof error === "object" && "kind" in error && error.kind === "audio-format-mismatch")
220
+ );
221
+ }
222
+
87
223
  function writeUint32(target: Uint8Array, offset: number, value: number): void {
88
224
  new DataView(target.buffer).setUint32(offset, value, true);
89
225
  }
@@ -185,6 +321,7 @@ export function mergeAudioBuffers(buffers: readonly ArrayBuffer[], options: Merg
185
321
  const format = typeof options === "string" ? options : options?.format;
186
322
  if (!format) throw new UnsupportedMergeFormatError("");
187
323
  try {
324
+ validateAudioSpecifications(buffers.map((buffer) => inspectAudioSpecification(buffer, format)));
188
325
  if (isWavFormat(format)) return mergeWavBuffers(buffers);
189
326
  if (isMp3Format(format)) {
190
327
  const parts = buffers.map(stripMp3Tags);
@@ -207,7 +344,8 @@ export function mergeAudioBuffers(buffers: readonly ArrayBuffer[], options: Merg
207
344
  }
208
345
  throw new UnsupportedMergeFormatError(format);
209
346
  } catch (error) {
210
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
347
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
348
+ throw error;
211
349
  throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
212
350
  }
213
351
  }
@@ -298,6 +436,7 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
298
436
  textRange?: { start: number; end: number };
299
437
  originalTextRange?: { start: number; end: number };
300
438
  sourceNodePath?: string[];
439
+ mappingStatus: "exact" | "fallback" | "unmapped";
301
440
  } => {
302
441
  const marker = markerName ? sourceMarkers.find((candidate) => candidate.name === markerName) : undefined;
303
442
  if (marker) {
@@ -305,16 +444,25 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
305
444
  originalTextRange: { ...marker.originalTextRange },
306
445
  sourceNodePath: [...marker.sourceNodePath],
307
446
  textRange: { ...marker.originalTextRange },
447
+ mappingStatus: "exact",
308
448
  };
309
449
  }
310
- if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) return {};
450
+ if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) {
451
+ const unmapped: { mappingStatus: "unmapped" } = { mappingStatus: "unmapped" };
452
+ Object.defineProperty(unmapped, "mappingStatus", { value: "unmapped", enumerable: false });
453
+ return unmapped;
454
+ }
311
455
  const value = text ?? "";
312
456
  let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? (offsetHint as number) : -1;
313
- if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value)
457
+ let mappingStatus: "exact" | "fallback" | "unmapped" = "exact";
458
+ if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value) {
314
459
  localStart = -1;
460
+ mappingStatus = "fallback";
461
+ }
315
462
  if (localStart < 0 || localStart > sourceText.length) {
316
463
  localStart = value ? sourceText.indexOf(value, sourceEventCursor) : sourceEventCursor;
317
464
  if (localStart < 0) localStart = value ? sourceText.indexOf(value) : sourceEventCursor;
465
+ mappingStatus = "fallback";
318
466
  }
319
467
  localStart = Math.max(0, localStart);
320
468
  const localEnd = Math.min(sourceText.length, localStart + value.length);
@@ -335,6 +483,7 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
335
483
  : config.sourceNodePath
336
484
  ? { sourceNodePath: [...config.sourceNodePath] }
337
485
  : {}),
486
+ mappingStatus: segment || config.sourceTextRange || config.sourceNodePath ? mappingStatus : "unmapped",
338
487
  };
339
488
  };
340
489
  synthesizer.wordBoundary = (_sender, event) => {
@@ -384,22 +533,31 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
384
533
  );
385
534
  const durationMs = result.audioDuration ? ticksToMilliseconds(result.audioDuration) : eventDurationMs;
386
535
  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
- });
536
+ const addSourceMetadata = <T extends { audioOffsetMs: number; mappingStatus: "exact" | "fallback" | "unmapped" }>(
537
+ event: T,
538
+ ): T => {
539
+ const mapped = {
540
+ ...event,
541
+ ...(config.sourceTextRange && !("textRange" in event) ? { textRange: { ...config.sourceTextRange } } : {}),
542
+ ...(config.sourceTextRange && !("originalTextRange" in event)
543
+ ? { originalTextRange: { ...config.sourceTextRange } }
544
+ : {}),
545
+ ...(config.chunkIndex !== undefined ? { chunkIndex: config.chunkIndex } : {}),
546
+ ...(config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {}),
547
+ ...(requestId ? { requestId } : {}),
548
+ } as T;
549
+ if (event.mappingStatus === "unmapped")
550
+ Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
551
+ return mapped;
552
+ };
397
553
  const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
398
554
  const sourceVisemes = visemes.map((viseme) => addSourceMetadata(viseme));
399
555
  const sourceBookmarks = bookmarks.map((bookmark) => addSourceMetadata(bookmark));
400
556
  resolve({
401
557
  audioData: result.audioData,
402
558
  durationMs,
559
+ audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
560
+ mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
403
561
  ...(config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {}),
404
562
  ...(requestId ? { requestId } : {}),
405
563
  ...(sourceBoundaries.length > 0
@@ -428,12 +586,82 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
428
586
  });
429
587
  }
430
588
 
431
- /** Synthesizes chunks sequentially, annotates synchronization events, and merges the results. */
589
+ function isRetryableSynthesisError(error: unknown): boolean {
590
+ if (error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError) return false;
591
+ if (error instanceof AzureTtsError && error.status !== 0)
592
+ return error.status === 429 || (error.status >= 500 && error.status < 600);
593
+ const message = error instanceof Error ? error.message : String(error);
594
+ if (/\b4\d{2}\b/.test(message)) return false;
595
+ const status = error && typeof error === "object" && "status" in error ? error.status : undefined;
596
+ if (typeof status === "number") return status === 429 || (status >= 500 && status < 600);
597
+ return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
598
+ }
599
+
600
+ function retryDelay(options: RetryOptions, retryAttempt: number): number {
601
+ const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
602
+ return Math.floor(Math.random() * (base + 1));
603
+ }
604
+
605
+ function resolveConcurrency(value: number | undefined, total: number): number {
606
+ if (value === undefined) return 1;
607
+ if (value === Infinity) return Math.max(1, total);
608
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
609
+ }
610
+
611
+ async function waitForRetry(delayMs: number, signal?: AbortSignal): Promise<void> {
612
+ if (signal?.aborted) throw new SynthesisCancelledError();
613
+ if (delayMs <= 0) return;
614
+ await new Promise<void>((resolve, reject) => {
615
+ let timer: ReturnType<typeof setTimeout>;
616
+ const abort = () => {
617
+ clearTimeout(timer);
618
+ signal?.removeEventListener("abort", abort);
619
+ reject(new SynthesisCancelledError());
620
+ };
621
+ timer = setTimeout(() => {
622
+ signal?.removeEventListener("abort", abort);
623
+ resolve();
624
+ }, delayMs);
625
+ if (signal) {
626
+ signal.addEventListener("abort", abort, { once: true });
627
+ }
628
+ });
629
+ }
630
+
631
+ async function synthesizeWithRetry(
632
+ ssml: string,
633
+ config: TtsConfig,
634
+ retryOptions: RetryOptions | undefined,
635
+ onRetry: (retryAttempt: number, nextRetryDelayMs: number) => void,
636
+ ): Promise<SsmlSynthesisResult> {
637
+ const options = retryOptions
638
+ ? {
639
+ maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
640
+ initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
641
+ maxDelayMs: Math.max(0, retryOptions.maxDelayMs),
642
+ }
643
+ : undefined;
644
+ let attempt = 0;
645
+ while (true) {
646
+ if (config.signal?.aborted) throw new SynthesisCancelledError();
647
+ try {
648
+ return await synthesizeSsml(ssml, config);
649
+ } catch (error) {
650
+ if (!options || attempt >= options.maxRetries || !isRetryableSynthesisError(error)) throw error;
651
+ attempt += 1;
652
+ const delayMs = retryDelay(options, attempt);
653
+ onRetry(attempt, delayMs);
654
+ await waitForRetry(delayMs, config.signal);
655
+ }
656
+ }
657
+ }
658
+
659
+ /** Synthesizes chunks with bounded concurrency, retries transient failures, and merges in chunk order. */
432
660
  export async function synthesizeSsmlChunks(
433
661
  chunks: readonly (SsmlSynthesisChunk | string)[],
434
662
  config: TtsConfig,
435
663
  ): Promise<SsmlSynthesisResult> {
436
- const results: SsmlSynthesisResult[] = [];
664
+ const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
437
665
  const totalChunks = chunks.length;
438
666
  const report = (event: SynthesisProgressEvent): void => config.onProgress?.(event);
439
667
  for (const [index, chunk] of chunks.entries()) {
@@ -448,56 +676,85 @@ export async function synthesizeSsmlChunks(
448
676
  durationMs: 0,
449
677
  });
450
678
  }
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);
476
- report({
477
- currentChunk: index + 1,
478
- totalChunks,
479
- percent: totalChunks === 0 ? 100 : Math.round(((index + 1) / totalChunks) * 100),
480
- chunkIndex: index,
481
- originalTextRange: input.originalTextRange,
482
- status: "success",
483
- durationMs: Date.now() - startedAt,
484
- });
485
- } catch (error) {
679
+ let completed = 0;
680
+ let nextIndex = 0;
681
+ const concurrency = resolveConcurrency(config.concurrency, chunks.length);
682
+ const worker = async (): Promise<void> => {
683
+ while (true) {
684
+ const index = nextIndex++;
685
+ if (index >= chunks.length) return;
686
+ const chunk = chunks[index];
687
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
486
688
  report({
487
- currentChunk: index,
689
+ currentChunk: completed,
488
690
  totalChunks,
489
- percent: totalChunks === 0 ? 100 : Math.round((index / totalChunks) * 100),
691
+ percent: totalChunks === 0 ? 100 : Math.round((completed / totalChunks) * 100),
490
692
  chunkIndex: index,
491
693
  originalTextRange: input.originalTextRange,
492
- status: "failed",
493
- durationMs: Date.now() - startedAt,
494
- error,
694
+ status: "synthesizing",
695
+ durationMs: 0,
495
696
  });
496
- throw error;
697
+ const startedAt = Date.now();
698
+ try {
699
+ const result = await synthesizeWithRetry(
700
+ input.ssml,
701
+ {
702
+ ...config,
703
+ ...(input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {}),
704
+ ...((input.sourceNodePath ?? config.sourceNodePath)
705
+ ? { sourceNodePath: [...(input.sourceNodePath ?? config.sourceNodePath ?? [])] }
706
+ : {}),
707
+ ...(input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {}),
708
+ ...(input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {}),
709
+ chunkIndex: index,
710
+ onProgress: undefined,
711
+ },
712
+ config.retryOptions,
713
+ (retryAttempt, nextRetryDelayMs) =>
714
+ report({
715
+ currentChunk: completed,
716
+ totalChunks,
717
+ percent: totalChunks === 0 ? 100 : Math.round((completed / totalChunks) * 100),
718
+ chunkIndex: index,
719
+ originalTextRange: input.originalTextRange,
720
+ status: "synthesizing",
721
+ durationMs: Date.now() - startedAt,
722
+ retryAttempt,
723
+ nextRetryDelayMs,
724
+ isRetrying: true,
725
+ }),
726
+ );
727
+ results[index] = result;
728
+ completed += 1;
729
+ report({
730
+ currentChunk: completed,
731
+ totalChunks,
732
+ percent: totalChunks === 0 ? 100 : Math.round((completed / totalChunks) * 100),
733
+ chunkIndex: index,
734
+ originalTextRange: input.originalTextRange,
735
+ status: "success",
736
+ durationMs: Date.now() - startedAt,
737
+ });
738
+ } catch (error) {
739
+ report({
740
+ currentChunk: completed,
741
+ totalChunks,
742
+ percent: totalChunks === 0 ? 100 : Math.round((completed / totalChunks) * 100),
743
+ chunkIndex: index,
744
+ originalTextRange: input.originalTextRange,
745
+ status: "failed",
746
+ durationMs: Date.now() - startedAt,
747
+ error,
748
+ });
749
+ throw error;
750
+ }
497
751
  }
498
- }
499
- return mergeSynthesisResults(results, {
500
- format: (config.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3") as AzureTtsOutputFormat,
752
+ };
753
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
754
+ const orderedResults = results.filter((result): result is SsmlSynthesisResult => result !== undefined);
755
+ return mergeSynthesisResults(orderedResults, {
756
+ format: (config.outputFormat ?? DEFAULT_OUTPUT_FORMAT) as AzureTtsOutputFormat,
757
+ signal: config.signal,
501
758
  });
502
759
  }
503
760
 
@@ -506,6 +763,8 @@ function createMergedResult(
506
763
  results: readonly SsmlSynthesisResult[],
507
764
  audioData: ArrayBuffer,
508
765
  format: string,
766
+ audioSpec?: AudioSpecification,
767
+ outputMimeType?: string,
509
768
  ): MergedSynthesisResult {
510
769
  const boundaries: NonNullable<SsmlSynthesisResult["boundaries"]> = [];
511
770
  const visemes: NonNullable<SsmlSynthesisResult["visemes"]> = [];
@@ -530,6 +789,7 @@ function createMergedResult(
530
789
  ...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
531
790
  ...(textRange ? { textRange: { ...textRange } } : {}),
532
791
  ...(requestId ? { requestId } : {}),
792
+ mappingStatus: boundary.mappingStatus ?? "unmapped",
533
793
  });
534
794
  }
535
795
  for (const viseme of result.visemes ?? []) {
@@ -545,6 +805,7 @@ function createMergedResult(
545
805
  ...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
546
806
  ...(textRange ? { textRange: { ...textRange } } : {}),
547
807
  ...(requestId ? { requestId } : {}),
808
+ mappingStatus: viseme.mappingStatus ?? "unmapped",
548
809
  });
549
810
  }
550
811
  for (const bookmark of result.bookmarks ?? []) {
@@ -560,6 +821,7 @@ function createMergedResult(
560
821
  ...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
561
822
  ...(textRange ? { textRange: { ...textRange } } : {}),
562
823
  ...(requestId ? { requestId } : {}),
824
+ mappingStatus: bookmark.mappingStatus ?? "unmapped",
563
825
  });
564
826
  }
565
827
  durationOffset += Math.max(0, result.durationMs);
@@ -569,6 +831,8 @@ function createMergedResult(
569
831
  audioData,
570
832
  durationMs: durationOffset,
571
833
  mimeType: resolveMimeType(format),
834
+ audioSpec: audioSpec ?? formatAudioSpecification(format),
835
+ ...(outputMimeType ? { mimeType: outputMimeType } : {}),
572
836
  ...(boundaries.length > 0 ? { boundaries, wordBoundary: boundaries, wordBoundaries: boundaries } : {}),
573
837
  ...(visemes.length > 0 ? { visemes } : {}),
574
838
  ...(bookmarks.length > 0 ? { bookmarks } : {}),
@@ -594,22 +858,53 @@ export function mergeSynthesisResults(
594
858
  const format = resolvedOptions?.format;
595
859
  if (!format) throw new UnsupportedMergeFormatError("");
596
860
  const buffers = results.map((result) => result.audioData);
861
+ const inputSpecs = results.map((result) => result.audioSpec ?? inspectAudioSpecification(result.audioData, format));
862
+ validateAudioSpecifications(inputSpecs);
863
+ const signal = resolvedOptions.signal ?? new AbortController().signal;
864
+ if (signal.aborted) throw new SynthesisCancelledError();
597
865
  if (resolvedOptions.customMerger) {
598
866
  return Promise.resolve()
599
- .then(() => resolvedOptions.customMerger?.(buffers, format))
867
+ .then(() =>
868
+ resolvedOptions.customMerger?.(buffers, {
869
+ format,
870
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
871
+ inputSpecs,
872
+ signal,
873
+ }),
874
+ )
600
875
  .then((merged) => {
601
876
  if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
602
- return createMergedResult(results, merged, format);
877
+ if (
878
+ !(merged instanceof ArrayBuffer) ||
879
+ (buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
880
+ )
881
+ throw new MergeError("The custom audio merger returned an invalid audio buffer.");
882
+ if (signal.aborted) throw new SynthesisCancelledError();
883
+ return createMergedResult(
884
+ results,
885
+ merged,
886
+ format,
887
+ inspectAudioSpecification(merged, format),
888
+ resolvedOptions.outputMimeType,
889
+ );
603
890
  })
604
891
  .catch((error: unknown) => {
605
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
892
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
893
+ throw error;
606
894
  throw new MergeError(`Custom audio merger failed for format "${format}".`, error);
607
895
  });
608
896
  }
609
897
  try {
610
- return createMergedResult(results, mergeAudioBuffers(buffers, { format }), format);
898
+ return createMergedResult(
899
+ results,
900
+ mergeAudioBuffers(buffers, { format }),
901
+ format,
902
+ inputSpecs[0],
903
+ resolvedOptions.outputMimeType,
904
+ );
611
905
  } catch (error) {
612
- if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
906
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
907
+ throw error;
613
908
  throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
614
909
  }
615
910
  }
package/src/types.ts CHANGED
@@ -18,6 +18,26 @@ export interface TtsConfig {
18
18
  /** Exact source text segments used to map individual Azure events. */
19
19
  sourceTextSegments?: SsmlSourceTextSegment[];
20
20
  sourceMarkers?: SsmlSourceMarker[];
21
+ concurrency?: number;
22
+ retryOptions?: RetryOptions;
23
+ }
24
+
25
+ export type MappingStatus = "exact" | "fallback" | "unmapped";
26
+
27
+ export interface AudioSpecification {
28
+ format: string;
29
+ mimeType: string;
30
+ codec: "pcm" | "mp3" | "opus" | "silk" | "unknown";
31
+ sampleRate: number;
32
+ channels: number;
33
+ bitrate?: number;
34
+ isCompressed: boolean;
35
+ }
36
+
37
+ export interface RetryOptions {
38
+ maxRetries: number;
39
+ initialDelayMs: number;
40
+ maxDelayMs: number;
21
41
  }
22
42
 
23
43
  export type SynthesisChunkStatus = "pending" | "synthesizing" | "success" | "failed";
@@ -36,6 +56,7 @@ export interface SsmlSynthesisBoundary {
36
56
  /** Audio offset within the originating chunk before merge. */
37
57
  chunkAudioOffsetMs?: number;
38
58
  requestId?: string;
59
+ mappingStatus: MappingStatus;
39
60
  }
40
61
 
41
62
  export interface SsmlSynthesisViseme {
@@ -47,6 +68,7 @@ export interface SsmlSynthesisViseme {
47
68
  originalTextRange?: SsmlTextRange;
48
69
  chunkAudioOffsetMs?: number;
49
70
  requestId?: string;
71
+ mappingStatus: MappingStatus;
50
72
  }
51
73
 
52
74
  export interface SsmlSynthesisBookmark {
@@ -58,6 +80,7 @@ export interface SsmlSynthesisBookmark {
58
80
  originalTextRange?: SsmlTextRange;
59
81
  chunkAudioOffsetMs?: number;
60
82
  requestId?: string;
83
+ mappingStatus: MappingStatus;
61
84
  }
62
85
 
63
86
  /** Audio and Azure Speech synchronization events emitted for one SSML request. */
@@ -77,6 +100,7 @@ export interface SsmlSynthesisResult {
77
100
  textRange?: { start: number; end: number };
78
101
  /** MIME type of a result produced by an explicit merge operation. */
79
102
  mimeType?: string;
103
+ audioSpec?: AudioSpecification;
80
104
  }
81
105
 
82
106
  export interface MergedSynthesisResult extends SsmlSynthesisResult {
@@ -97,6 +121,8 @@ export interface SynthesizeChunksOptions {
97
121
  signal?: AbortSignal;
98
122
  timeoutMs?: number;
99
123
  sourceNodePath?: string[];
124
+ concurrency?: number;
125
+ retryOptions?: RetryOptions;
100
126
  }
101
127
 
102
128
  export interface SynthesisProgressEvent {
@@ -109,6 +135,9 @@ export interface SynthesisProgressEvent {
109
135
  status: SynthesisChunkStatus;
110
136
  durationMs: number;
111
137
  error?: unknown;
138
+ retryAttempt?: number;
139
+ nextRetryDelayMs?: number;
140
+ isRetrying?: boolean;
112
141
  }
113
142
 
114
143
  export interface AzureTtsLogger {
@@ -127,4 +156,6 @@ export interface AzureTtsClientOptions {
127
156
  outputFormat?: string;
128
157
  logger?: AzureTtsLogger;
129
158
  onProgress?: (event: SynthesisProgressEvent) => void;
159
+ concurrency?: number;
160
+ retryOptions?: RetryOptions;
130
161
  }