@ssml-builder-js/azure-tts-client 2.14.0 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/synthesis.ts CHANGED
@@ -1,10 +1,51 @@
1
1
  import * as SpeechSDK from "microsoft-cognitiveservices-speech-sdk";
2
- import { createSpeechSdkError, UnsupportedMergeFormatError } from "./errors.ts";
2
+ import { getSsmlSourceMap } from "@ssml-builder-js/ssml-core";
3
+ import {
4
+ AzureTtsError,
5
+ MergeError,
6
+ AudioFormatMismatchError,
7
+ SynthesisCancelledError,
8
+ SynthesisTimeoutError,
9
+ toSynthesisError,
10
+ UnsupportedMergeFormatError,
11
+ } from "./errors.ts";
12
+ import { DEFAULT_OUTPUT_FORMAT, resolveMimeType, type AzureTtsOutputFormat } from "./outputFormats.ts";
3
13
  import { createSpeechConfig } from "./speechConfig.ts";
4
- import type { SsmlSynthesisChunk, SsmlSynthesisResult, SynthesisProgressEvent, TtsConfig } from "./types.ts";
14
+ import type {
15
+ MergedSynthesisResult,
16
+ SsmlSynthesisChunk,
17
+ SsmlSynthesisResult,
18
+ AudioSpecification,
19
+ SynthesisProgressEvent,
20
+ TtsConfig,
21
+ RetryOptions,
22
+ } from "./types.ts";
5
23
 
6
24
  export type MergeAudioFormat = "wav" | "mp3" | "raw";
7
25
 
26
+ export interface MergeAudioOptions {
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;
39
+ }
40
+
41
+ export interface MergeSynthesisOptions extends MergeAudioOptions {
42
+ customMerger?: (buffers: ArrayBuffer[], context: CustomMergerContext) => Promise<ArrayBuffer> | ArrayBuffer;
43
+ }
44
+
45
+ type AsyncMergeSynthesisOptions = MergeSynthesisOptions & {
46
+ customMerger: NonNullable<MergeSynthesisOptions["customMerger"]>;
47
+ };
48
+
8
49
  function ascii(bytes: Uint8Array, offset: number, value: string): boolean {
9
50
  return [...value].every((character, index) => bytes[offset + index] === character.charCodeAt(0));
10
51
  }
@@ -58,6 +99,127 @@ function parseWav(buffer: ArrayBuffer): ParsedWav {
58
99
  return { chunks, data, format };
59
100
  }
60
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
+
61
223
  function writeUint32(target: Uint8Array, offset: number, value: number): void {
62
224
  new DataView(target.buffer).setUint32(offset, value, true);
63
225
  }
@@ -154,28 +316,38 @@ export function canMergeAudioFormat(format: string): boolean {
154
316
  }
155
317
 
156
318
  /** Merges audio buffers while preserving the invariants of supported containers. */
157
- export function mergeAudioBuffers(buffers: readonly ArrayBuffer[], format: string): ArrayBuffer {
158
- if (isWavFormat(format)) return mergeWavBuffers(buffers);
159
- if (isMp3Format(format)) {
160
- const parts = buffers.map(stripMp3Tags);
161
- const output = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0));
162
- let offset = 0;
163
- for (const part of parts) {
164
- output.set(part, offset);
165
- offset += part.byteLength;
319
+ export function mergeAudioBuffers(buffers: readonly ArrayBuffer[], options: MergeAudioOptions): ArrayBuffer;
320
+ export function mergeAudioBuffers(buffers: readonly ArrayBuffer[], options: MergeAudioOptions | string): ArrayBuffer {
321
+ const format = typeof options === "string" ? options : options?.format;
322
+ if (!format) throw new UnsupportedMergeFormatError("");
323
+ try {
324
+ validateAudioSpecifications(buffers.map((buffer) => inspectAudioSpecification(buffer, format)));
325
+ if (isWavFormat(format)) return mergeWavBuffers(buffers);
326
+ if (isMp3Format(format)) {
327
+ const parts = buffers.map(stripMp3Tags);
328
+ const output = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0));
329
+ let offset = 0;
330
+ for (const part of parts) {
331
+ output.set(part, offset);
332
+ offset += part.byteLength;
333
+ }
334
+ return output.buffer;
166
335
  }
167
- return output.buffer;
168
- }
169
- if (isRawFormat(format)) {
170
- const output = new Uint8Array(buffers.reduce((total, buffer) => total + buffer.byteLength, 0));
171
- let offset = 0;
172
- for (const buffer of buffers) {
173
- output.set(new Uint8Array(buffer), offset);
174
- offset += buffer.byteLength;
336
+ if (isRawFormat(format)) {
337
+ const output = new Uint8Array(buffers.reduce((total, buffer) => total + buffer.byteLength, 0));
338
+ let offset = 0;
339
+ for (const buffer of buffers) {
340
+ output.set(new Uint8Array(buffer), offset);
341
+ offset += buffer.byteLength;
342
+ }
343
+ return output.buffer;
175
344
  }
176
- return output.buffer;
345
+ throw new UnsupportedMergeFormatError(format);
346
+ } catch (error) {
347
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
348
+ throw error;
349
+ throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
177
350
  }
178
- throw new UnsupportedMergeFormatError(format);
179
351
  }
180
352
 
181
353
  function closeSpeechResources(speechConfig: SpeechSDK.SpeechConfig, synthesizer: SpeechSDK.SpeechSynthesizer): void {
@@ -192,7 +364,7 @@ const ticksToMilliseconds = (ticks: number): number => Math.max(0, ticks) / 10_0
192
364
 
193
365
  export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<SsmlSynthesisResult> {
194
366
  if (config.signal?.aborted) {
195
- throw createSpeechSdkError("Speech synthesis was cancelled.");
367
+ throw new SynthesisCancelledError();
196
368
  }
197
369
 
198
370
  const speechConfig = createSpeechConfig(config);
@@ -217,24 +389,129 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
217
389
  settled = true;
218
390
  cleanup();
219
391
  closeResources();
220
- reject(createSpeechSdkError(error));
392
+ reject(toSynthesisError(error));
221
393
  };
222
394
 
223
395
  const boundaries: SsmlSynthesisResult["boundaries"] = [];
224
396
  const visemes: SsmlSynthesisResult["visemes"] = [];
225
397
  const bookmarks: SsmlSynthesisResult["bookmarks"] = [];
398
+ let sourceEventCursor = 0;
399
+ let generatedSourceMap: ReturnType<typeof getSsmlSourceMap> | undefined;
400
+ if (!config.sourceTextSegments && !config.sourceMarkers) {
401
+ try {
402
+ generatedSourceMap = getSsmlSourceMap(ssml);
403
+ } catch {
404
+ generatedSourceMap = undefined;
405
+ }
406
+ }
407
+ const sourceBaseOffset = config.sourceTextRange?.start ?? 0;
408
+ const sourceSegments =
409
+ config.sourceTextSegments ??
410
+ generatedSourceMap?.segments.map((segment) => ({
411
+ ...segment,
412
+ range: {
413
+ start: segment.range.start + sourceBaseOffset,
414
+ end: segment.range.end + sourceBaseOffset,
415
+ },
416
+ sourceNodePath: [...segment.sourceNodePath],
417
+ })) ??
418
+ [];
419
+ const sourceMarkers =
420
+ config.sourceMarkers ??
421
+ generatedSourceMap?.markers.map((marker) => ({
422
+ ...marker,
423
+ originalTextRange: {
424
+ start: marker.originalTextRange.start + sourceBaseOffset,
425
+ end: marker.originalTextRange.end + sourceBaseOffset,
426
+ },
427
+ sourceNodePath: [...marker.sourceNodePath],
428
+ })) ??
429
+ [];
430
+ const sourceText = sourceSegments.map((segment) => segment.text).join("");
431
+ const mapSourceEvent = (
432
+ text: string | undefined,
433
+ offsetHint: number | undefined,
434
+ markerName?: string,
435
+ ): {
436
+ textRange?: { start: number; end: number };
437
+ originalTextRange?: { start: number; end: number };
438
+ sourceNodePath?: string[];
439
+ mappingStatus: "exact" | "fallback" | "unmapped";
440
+ } => {
441
+ const marker = markerName ? sourceMarkers.find((candidate) => candidate.name === markerName) : undefined;
442
+ if (marker) {
443
+ return {
444
+ originalTextRange: { ...marker.originalTextRange },
445
+ sourceNodePath: [...marker.sourceNodePath],
446
+ textRange: { ...marker.originalTextRange },
447
+ mappingStatus: "exact",
448
+ };
449
+ }
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
+ }
455
+ const value = text ?? "";
456
+ let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? (offsetHint as number) : -1;
457
+ let mappingStatus: "exact" | "fallback" | "unmapped" = "exact";
458
+ if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value) {
459
+ localStart = -1;
460
+ mappingStatus = "fallback";
461
+ }
462
+ if (localStart < 0 || localStart > sourceText.length) {
463
+ localStart = value ? sourceText.indexOf(value, sourceEventCursor) : sourceEventCursor;
464
+ if (localStart < 0) localStart = value ? sourceText.indexOf(value) : sourceEventCursor;
465
+ mappingStatus = "fallback";
466
+ }
467
+ localStart = Math.max(0, localStart);
468
+ const localEnd = Math.min(sourceText.length, localStart + value.length);
469
+ sourceEventCursor = Math.max(sourceEventCursor, localEnd);
470
+ const baseStart = config.sourceTextRange?.start ?? sourceSegments[0]?.range.start ?? 0;
471
+ const fallbackRange = { start: baseStart + localStart, end: baseStart + localEnd };
472
+ const segment =
473
+ sourceSegments.find(({ range }) => range.start <= fallbackRange.start && range.end > fallbackRange.start) ??
474
+ sourceSegments.find(({ range }) => range.end > fallbackRange.start) ??
475
+ (value.length === 0
476
+ ? sourceSegments.find(({ range }) => range.start <= fallbackRange.start && range.end >= fallbackRange.start)
477
+ : undefined);
478
+ return {
479
+ originalTextRange: { ...fallbackRange },
480
+ textRange: { ...fallbackRange },
481
+ ...(segment
482
+ ? { sourceNodePath: [...segment.sourceNodePath] }
483
+ : config.sourceNodePath
484
+ ? { sourceNodePath: [...config.sourceNodePath] }
485
+ : {}),
486
+ mappingStatus: segment || config.sourceTextRange || config.sourceNodePath ? mappingStatus : "unmapped",
487
+ };
488
+ };
226
489
  synthesizer.wordBoundary = (_sender, event) => {
227
490
  boundaries.push({
228
491
  text: event.text,
229
492
  audioOffsetMs: ticksToMilliseconds(event.audioOffset),
230
493
  durationMs: ticksToMilliseconds(event.duration),
494
+ ...mapSourceEvent(
495
+ event.text,
496
+ (event as SpeechSDK.SpeechSynthesisWordBoundaryEventArgs & { textOffset?: number }).textOffset,
497
+ ),
231
498
  });
232
499
  };
233
500
  synthesizer.visemeReceived = (_sender, event) => {
234
- visemes.push({ visemeId: event.visemeId, audioOffsetMs: ticksToMilliseconds(event.audioOffset) });
501
+ const eventWithOffset = event as SpeechSDK.SpeechSynthesisVisemeEventArgs & { textOffset?: number };
502
+ visemes.push({
503
+ visemeId: event.visemeId,
504
+ audioOffsetMs: ticksToMilliseconds(event.audioOffset),
505
+ ...mapSourceEvent(undefined, eventWithOffset.textOffset),
506
+ });
235
507
  };
236
508
  synthesizer.bookmarkReached = (_sender, event) => {
237
- bookmarks.push({ name: event.text, audioOffsetMs: ticksToMilliseconds(event.audioOffset) });
509
+ const eventWithOffset = event as SpeechSDK.SpeechSynthesisBookmarkEventArgs & { textOffset?: number };
510
+ bookmarks.push({
511
+ name: event.text,
512
+ audioOffsetMs: ticksToMilliseconds(event.audioOffset),
513
+ ...mapSourceEvent(undefined, eventWithOffset.textOffset, event.text),
514
+ });
238
515
  };
239
516
 
240
517
  const cb = (result: SpeechSDK.SpeechSynthesisResult) => {
@@ -256,20 +533,31 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
256
533
  );
257
534
  const durationMs = result.audioDuration ? ticksToMilliseconds(result.audioDuration) : eventDurationMs;
258
535
  const requestId = (result as SpeechSDK.SpeechSynthesisResult & { resultId?: string }).resultId;
259
- const addSourceMetadata = <T extends { audioOffsetMs: number }>(event: T): T => ({
260
- ...event,
261
- ...(config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {}),
262
- ...(config.sourceTextRange ? { originalTextRange: { ...config.sourceTextRange } } : {}),
263
- ...(config.chunkIndex !== undefined ? { chunkIndex: config.chunkIndex } : {}),
264
- ...(config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {}),
265
- ...(requestId ? { requestId } : {}),
266
- });
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
+ };
267
553
  const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
268
554
  const sourceVisemes = visemes.map((viseme) => addSourceMetadata(viseme));
269
555
  const sourceBookmarks = bookmarks.map((bookmark) => addSourceMetadata(bookmark));
270
556
  resolve({
271
557
  audioData: result.audioData,
272
558
  durationMs,
559
+ audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
560
+ mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
273
561
  ...(config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {}),
274
562
  ...(requestId ? { requestId } : {}),
275
563
  ...(sourceBoundaries.length > 0
@@ -282,12 +570,12 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
282
570
 
283
571
  try {
284
572
  if (config.signal) {
285
- abortHandler = () => rejectWithError("Speech synthesis was cancelled.");
573
+ abortHandler = () => rejectWithError(new SynthesisCancelledError());
286
574
  config.signal.addEventListener("abort", abortHandler, { once: true });
287
575
  }
288
576
  if (config.timeoutMs !== undefined && config.timeoutMs > 0) {
289
577
  timeout = setTimeout(
290
- () => rejectWithError(`Speech synthesis timed out after ${config.timeoutMs} ms.`),
578
+ () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeoutMs} ms.`)),
291
579
  config.timeoutMs,
292
580
  );
293
581
  }
@@ -298,12 +586,82 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
298
586
  });
299
587
  }
300
588
 
301
- /** 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. */
302
660
  export async function synthesizeSsmlChunks(
303
661
  chunks: readonly (SsmlSynthesisChunk | string)[],
304
662
  config: TtsConfig,
305
663
  ): Promise<SsmlSynthesisResult> {
306
- const results: SsmlSynthesisResult[] = [];
664
+ const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
307
665
  const totalChunks = chunks.length;
308
666
  const report = (event: SynthesisProgressEvent): void => config.onProgress?.(event);
309
667
  for (const [index, chunk] of chunks.entries()) {
@@ -318,76 +676,102 @@ export async function synthesizeSsmlChunks(
318
676
  durationMs: 0,
319
677
  });
320
678
  }
321
- for (const [index, chunk] of chunks.entries()) {
322
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
323
- report({
324
- currentChunk: index,
325
- totalChunks,
326
- percent: totalChunks === 0 ? 100 : Math.round((index / totalChunks) * 100),
327
- chunkIndex: index,
328
- originalTextRange: input.originalTextRange,
329
- status: "synthesizing",
330
- durationMs: 0,
331
- });
332
- const startedAt = Date.now();
333
- try {
334
- const result = await synthesizeSsml(input.ssml, {
335
- ...config,
336
- ...(input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {}),
337
- ...(input.sourceNodePath ? { sourceNodePath: input.sourceNodePath } : {}),
338
- chunkIndex: index,
339
- onProgress: undefined,
340
- });
341
- results.push(result);
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;
342
688
  report({
343
- currentChunk: index + 1,
689
+ currentChunk: completed,
344
690
  totalChunks,
345
- percent: totalChunks === 0 ? 100 : Math.round(((index + 1) / totalChunks) * 100),
691
+ percent: totalChunks === 0 ? 100 : Math.round((completed / totalChunks) * 100),
346
692
  chunkIndex: index,
347
693
  originalTextRange: input.originalTextRange,
348
- status: "success",
349
- durationMs: Date.now() - startedAt,
694
+ status: "synthesizing",
695
+ durationMs: 0,
350
696
  });
351
- } catch (error) {
352
- report({
353
- currentChunk: index,
354
- totalChunks,
355
- percent: totalChunks === 0 ? 100 : Math.round((index / totalChunks) * 100),
356
- chunkIndex: index,
357
- originalTextRange: input.originalTextRange,
358
- status: "failed",
359
- durationMs: Date.now() - startedAt,
360
- error,
361
- });
362
- 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
+ }
363
751
  }
364
- }
365
- return mergeSynthesisResults(results, config.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3");
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,
758
+ });
366
759
  }
367
760
 
368
761
  /** Concatenates audio buffers and shifts all synchronization events by prior chunk durations. */
369
- export function mergeSynthesisResults(results: readonly SsmlSynthesisResult[], format?: string): SsmlSynthesisResult {
370
- const audioData = format
371
- ? new Uint8Array(
372
- mergeAudioBuffers(
373
- results.map((result) => result.audioData),
374
- format,
375
- ),
376
- )
377
- : new Uint8Array(results.reduce((total, result) => total + result.audioData.byteLength, 0));
378
- if (!format) {
379
- let offset = 0;
380
- for (const result of results) {
381
- audioData.set(new Uint8Array(result.audioData), offset);
382
- offset += result.audioData.byteLength;
383
- }
384
- }
762
+ function createMergedResult(
763
+ results: readonly SsmlSynthesisResult[],
764
+ audioData: ArrayBuffer,
765
+ format: string,
766
+ audioSpec?: AudioSpecification,
767
+ outputMimeType?: string,
768
+ ): MergedSynthesisResult {
385
769
  const boundaries: NonNullable<SsmlSynthesisResult["boundaries"]> = [];
386
770
  const visemes: NonNullable<SsmlSynthesisResult["visemes"]> = [];
387
771
  const bookmarks: NonNullable<SsmlSynthesisResult["bookmarks"]> = [];
388
772
  let durationOffset = 0;
389
773
 
390
- for (const result of results) {
774
+ for (const [resultIndex, result] of results.entries()) {
391
775
  const chunkBoundaries =
392
776
  result.boundaries && result.boundaries.length > 0
393
777
  ? result.boundaries
@@ -400,11 +784,12 @@ export function mergeSynthesisResults(results: readonly SsmlSynthesisResult[], f
400
784
  ...boundary,
401
785
  audioOffsetMs: boundary.audioOffsetMs + durationOffset,
402
786
  chunkAudioOffsetMs: boundary.chunkAudioOffsetMs ?? boundary.audioOffsetMs,
403
- ...(boundary.chunkIndex === undefined ? { chunkIndex: results.indexOf(result) } : {}),
787
+ ...(boundary.chunkIndex === undefined ? { chunkIndex: resultIndex } : {}),
404
788
  ...(boundary.sourceNodePath ? { sourceNodePath: [...boundary.sourceNodePath] } : {}),
405
789
  ...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
406
790
  ...(textRange ? { textRange: { ...textRange } } : {}),
407
791
  ...(requestId ? { requestId } : {}),
792
+ mappingStatus: boundary.mappingStatus ?? "unmapped",
408
793
  });
409
794
  }
410
795
  for (const viseme of result.visemes ?? []) {
@@ -415,11 +800,12 @@ export function mergeSynthesisResults(results: readonly SsmlSynthesisResult[], f
415
800
  ...viseme,
416
801
  audioOffsetMs: viseme.audioOffsetMs + durationOffset,
417
802
  chunkAudioOffsetMs: viseme.chunkAudioOffsetMs ?? viseme.audioOffsetMs,
418
- ...(viseme.chunkIndex === undefined ? { chunkIndex: results.indexOf(result) } : {}),
803
+ ...(viseme.chunkIndex === undefined ? { chunkIndex: resultIndex } : {}),
419
804
  ...(viseme.sourceNodePath ? { sourceNodePath: [...viseme.sourceNodePath] } : {}),
420
805
  ...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
421
806
  ...(textRange ? { textRange: { ...textRange } } : {}),
422
807
  ...(requestId ? { requestId } : {}),
808
+ mappingStatus: viseme.mappingStatus ?? "unmapped",
423
809
  });
424
810
  }
425
811
  for (const bookmark of result.bookmarks ?? []) {
@@ -430,19 +816,23 @@ export function mergeSynthesisResults(results: readonly SsmlSynthesisResult[], f
430
816
  ...bookmark,
431
817
  audioOffsetMs: bookmark.audioOffsetMs + durationOffset,
432
818
  chunkAudioOffsetMs: bookmark.chunkAudioOffsetMs ?? bookmark.audioOffsetMs,
433
- ...(bookmark.chunkIndex === undefined ? { chunkIndex: results.indexOf(result) } : {}),
819
+ ...(bookmark.chunkIndex === undefined ? { chunkIndex: resultIndex } : {}),
434
820
  ...(bookmark.sourceNodePath ? { sourceNodePath: [...bookmark.sourceNodePath] } : {}),
435
821
  ...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
436
822
  ...(textRange ? { textRange: { ...textRange } } : {}),
437
823
  ...(requestId ? { requestId } : {}),
824
+ mappingStatus: bookmark.mappingStatus ?? "unmapped",
438
825
  });
439
826
  }
440
827
  durationOffset += Math.max(0, result.durationMs);
441
828
  }
442
829
 
443
830
  return {
444
- audioData: audioData.buffer,
831
+ audioData,
445
832
  durationMs: durationOffset,
833
+ mimeType: resolveMimeType(format),
834
+ audioSpec: audioSpec ?? formatAudioSpecification(format),
835
+ ...(outputMimeType ? { mimeType: outputMimeType } : {}),
446
836
  ...(boundaries.length > 0 ? { boundaries, wordBoundary: boundaries, wordBoundaries: boundaries } : {}),
447
837
  ...(visemes.length > 0 ? { visemes } : {}),
448
838
  ...(bookmarks.length > 0 ? { bookmarks } : {}),
@@ -451,6 +841,74 @@ export function mergeSynthesisResults(results: readonly SsmlSynthesisResult[], f
451
841
  };
452
842
  }
453
843
 
844
+ export function mergeSynthesisResults(
845
+ results: readonly SsmlSynthesisResult[],
846
+ options: AsyncMergeSynthesisOptions,
847
+ ): Promise<MergedSynthesisResult>;
848
+ export function mergeSynthesisResults(
849
+ results: readonly SsmlSynthesisResult[],
850
+ options: MergeAudioOptions,
851
+ ): MergedSynthesisResult;
852
+ export function mergeSynthesisResults(
853
+ results: readonly SsmlSynthesisResult[],
854
+ options: MergeSynthesisOptions | string,
855
+ ): SsmlSynthesisResult | Promise<SsmlSynthesisResult> {
856
+ const resolvedOptions: MergeSynthesisOptions =
857
+ typeof options === "string" ? { format: options as AzureTtsOutputFormat } : options;
858
+ const format = resolvedOptions?.format;
859
+ if (!format) throw new UnsupportedMergeFormatError("");
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();
865
+ if (resolvedOptions.customMerger) {
866
+ return Promise.resolve()
867
+ .then(() =>
868
+ resolvedOptions.customMerger?.(buffers, {
869
+ format,
870
+ outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
871
+ inputSpecs,
872
+ signal,
873
+ }),
874
+ )
875
+ .then((merged) => {
876
+ if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
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
+ );
890
+ })
891
+ .catch((error: unknown) => {
892
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
893
+ throw error;
894
+ throw new MergeError(`Custom audio merger failed for format "${format}".`, error);
895
+ });
896
+ }
897
+ try {
898
+ return createMergedResult(
899
+ results,
900
+ mergeAudioBuffers(buffers, { format }),
901
+ format,
902
+ inputSpecs[0],
903
+ resolvedOptions.outputMimeType,
904
+ );
905
+ } catch (error) {
906
+ if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
907
+ throw error;
908
+ throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
909
+ }
910
+ }
911
+
454
912
  /** Backward-compatible audio-only synthesis helper. */
455
913
  export async function synthesizeSpeech(ssml: string, config: TtsConfig): Promise<ArrayBuffer> {
456
914
  return (await synthesizeSsml(ssml, config)).audioData;