@ssml-builder-js/azure-tts-client 2.17.0 → 2.18.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
@@ -22,6 +22,7 @@ import type {
22
22
  RetryOptions,
23
23
  CustomAudioMerger,
24
24
  PostMergeValidator,
25
+ ChunkExecutionState,
25
26
  } from "./types.ts";
26
27
 
27
28
  export type MergeAudioFormat = "wav" | "mp3" | "raw";
@@ -34,6 +35,33 @@ export interface MergeAudioOptions {
34
35
 
35
36
  export type InputAudioSpecs = AudioSpecification[];
36
37
 
38
+ /**
39
+ * Creates a deterministic, runtime-independent fingerprint for a synthesis chunk.
40
+ * The complete SSML is included so changes to voice, language, prosody, or text
41
+ * invalidate a cached result even when those settings are nested in the markup.
42
+ */
43
+ export function computeChunkFingerprint(ssml: string, outputFormat = DEFAULT_OUTPUT_FORMAT): string {
44
+ const readAttribute = (name: string): string => {
45
+ const pattern = new RegExp(`(?:${name})\\s*=\\s*[\\"']([^\\"']*)`, "gi");
46
+ return [...ssml.matchAll(pattern)].map((match) => match[1] ?? "").join("|");
47
+ };
48
+ const payload = JSON.stringify({
49
+ ssml,
50
+ outputFormat,
51
+ voice: readAttribute("(?:name|voice)"),
52
+ language: readAttribute("(?:xml:lang|lang)"),
53
+ rate: readAttribute("rate"),
54
+ pitch: readAttribute("pitch"),
55
+ });
56
+ let hash = 0xcbf29ce484222325n;
57
+ const mask = 0xffffffffffffffffn;
58
+ for (let index = 0; index < payload.length; index += 1) {
59
+ hash ^= BigInt(payload.charCodeAt(index));
60
+ hash = (hash * 0x100000001b3n) & mask;
61
+ }
62
+ return `fnv1a64-${hash.toString(16).padStart(16, "0")}`;
63
+ }
64
+
37
65
  export interface MergeSynthesisOptions extends MergeAudioOptions {
38
66
  customMerger?: CustomAudioMerger;
39
67
  postMergeValidator?: PostMergeValidator;
@@ -96,9 +124,11 @@ function parseWav(buffer: ArrayBuffer): ParsedWav {
96
124
  return { chunks, data, format };
97
125
  }
98
126
 
99
- function formatNumber(format: string, pattern: RegExp, fallback: number): number {
100
- const match = pattern.exec(format);
101
- return match?.[1] ? Number(match[1]) : fallback;
127
+ function formatSampleRate(format: string): number {
128
+ const match = /(?:^|-)(\d+)(khz|hz)(?:-|$)/i.exec(format);
129
+ if (!match?.[1] || !match[2]) return 0;
130
+ const value = Number(match[1]);
131
+ return match[2].toLowerCase() === "khz" ? value * 1000 : value;
102
132
  }
103
133
 
104
134
  function formatChannels(format: string, fallback: number): number {
@@ -108,7 +138,7 @@ function formatChannels(format: string, fallback: number): number {
108
138
  }
109
139
 
110
140
  function formatAudioSpecification(format: string): AudioSpecification {
111
- const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
141
+ const sampleRate = formatSampleRate(format);
112
142
  const channels = formatChannels(format, 0);
113
143
  const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
114
144
  const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1000 : undefined;
@@ -118,9 +148,15 @@ function formatAudioSpecification(format: string): AudioSpecification {
118
148
  ? "opus"
119
149
  : /silk/i.test(format)
120
150
  ? "silk"
121
- : /pcm|mulaw|alaw|siren/i.test(format)
122
- ? "pcm"
123
- : "unknown";
151
+ : /mulaw|mu-law/i.test(format)
152
+ ? "mulaw"
153
+ : /alaw|a-law/i.test(format)
154
+ ? "alaw"
155
+ : /siren/i.test(format)
156
+ ? "siren"
157
+ : /pcm/i.test(format)
158
+ ? "pcm"
159
+ : "unknown";
124
160
  const bitDepthMatch = /(\d+)bit/i.exec(format);
125
161
  const container = /(?:wav|wave|riff)/i.test(format)
126
162
  ? "riff-wave"
@@ -143,7 +179,7 @@ function formatAudioSpecification(format: string): AudioSpecification {
143
179
  ...(bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {}),
144
180
  ...(container ? { container } : {}),
145
181
  isVbr: /vbr/i.test(format),
146
- isCompressed: codec === "mp3" || codec === "opus" || codec === "silk",
182
+ isCompressed: codec !== "pcm" && codec !== "unknown",
147
183
  };
148
184
  }
149
185
 
@@ -197,21 +233,47 @@ export function inspectAudioSpecification(buffer: ArrayBuffer, format: string):
197
233
  const channels = view.getUint16(2, true);
198
234
  const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
199
235
  const formatCode = view.getUint16(0, true);
236
+ const namedCodec = formatAudioSpecification(format).codec;
237
+ const codec: AudioSpecification["codec"] =
238
+ formatCode === 1
239
+ ? "pcm"
240
+ : formatCode === 6
241
+ ? "alaw"
242
+ : formatCode === 7
243
+ ? "mulaw"
244
+ : namedCodec === "siren"
245
+ ? "siren"
246
+ : "unknown";
200
247
  return {
201
248
  format,
202
249
  mimeType: "audio/wav",
203
- codec: formatCode === 1 ? "pcm" : "unknown",
250
+ codec,
204
251
  sampleRate,
205
252
  channels,
206
253
  ...(sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {}),
207
254
  bitDepth: bitsPerSample,
208
255
  container: "riff-wave",
209
256
  isVbr: false,
210
- isCompressed: formatCode !== 1,
257
+ isCompressed: codec !== "pcm" && codec !== "unknown",
211
258
  };
212
259
  }
213
260
  if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
214
- return formatAudioSpecification(format);
261
+ const specification = formatAudioSpecification(format);
262
+ if (specification.container === "raw") validateRawAudioBuffer(buffer, specification);
263
+ return specification;
264
+ }
265
+
266
+ function validateRawAudioBuffer(buffer: ArrayBuffer, specification: AudioSpecification): void {
267
+ if (specification.sampleRate <= 0 || specification.channels <= 0 || specification.bitDepth === undefined) {
268
+ throw new Error(`RAW audio format "${specification.format}" does not define a complete audio specification.`);
269
+ }
270
+ if (specification.codec === "siren" || specification.codec === "silk") return;
271
+ const bytesPerFrame = specification.channels * Math.ceil(specification.bitDepth / 8);
272
+ if (bytesPerFrame <= 0 || buffer.byteLength % bytesPerFrame !== 0) {
273
+ throw new Error(
274
+ `RAW audio buffer size ${buffer.byteLength} is not aligned to ${bytesPerFrame}-byte audio frames for "${specification.format}".`,
275
+ );
276
+ }
215
277
  }
216
278
 
217
279
  function validateAudioSpecifications(specs: readonly AudioSpecification[]): void {
@@ -221,6 +283,7 @@ function validateAudioSpecifications(specs: readonly AudioSpecification[]): void
221
283
  (spec) =>
222
284
  spec.sampleRate !== first.sampleRate ||
223
285
  spec.channels !== first.channels ||
286
+ spec.codec !== first.codec ||
224
287
  (first.bitrate !== undefined && spec.bitrate !== undefined && spec.bitrate !== first.bitrate) ||
225
288
  (first.bitDepth !== undefined && spec.bitDepth !== undefined && spec.bitDepth !== first.bitDepth) ||
226
289
  (first.container !== undefined && spec.container !== undefined && spec.container !== first.container) ||
@@ -323,6 +386,43 @@ function isRawFormat(format: string): boolean {
323
386
  return /^raw(?:-|$)/i.test(format);
324
387
  }
325
388
 
389
+ function validateMergedAudioBuffer(
390
+ merged: ArrayBuffer,
391
+ format: string,
392
+ buffers: readonly ArrayBuffer[],
393
+ inputSpecs: readonly AudioSpecification[],
394
+ outputMimeType: string,
395
+ ): AudioSpecification {
396
+ if (!(merged instanceof ArrayBuffer) || merged.byteLength === 0) {
397
+ throw new MergeError("The custom audio merger returned an empty or invalid audio buffer.");
398
+ }
399
+ const specification = inspectAudioSpecification(merged, format);
400
+ if (!outputMimeType.trim()) throw new MergeError("The custom audio merger output MIME type cannot be empty.");
401
+ const firstInput = inputSpecs[0];
402
+ if (
403
+ firstInput &&
404
+ (specification.sampleRate !== firstInput.sampleRate ||
405
+ specification.channels !== firstInput.channels ||
406
+ specification.codec !== firstInput.codec ||
407
+ (firstInput.bitDepth !== undefined && specification.bitDepth !== firstInput.bitDepth))
408
+ ) {
409
+ throw new AudioFormatMismatchError("The custom audio merger returned an incompatible audio stream.", [
410
+ ...inputSpecs,
411
+ specification,
412
+ ]);
413
+ }
414
+ if (isRawFormat(format)) {
415
+ const expectedSize = buffers.reduce((total, input) => total + input.byteLength, 0);
416
+ if (merged.byteLength !== expectedSize) {
417
+ throw new MergeError(
418
+ `The custom audio merger returned ${merged.byteLength} bytes; ${expectedSize} were expected.`,
419
+ );
420
+ }
421
+ validateRawAudioBuffer(merged, specification);
422
+ }
423
+ return specification;
424
+ }
425
+
326
426
  /** Returns whether the named output format can be safely concatenated without re-multiplexing. */
327
427
  export function resolveMergeAudioFormat(format: string): MergeAudioFormat | undefined {
328
428
  if (isWavFormat(format)) return "wav";
@@ -382,7 +482,7 @@ function closeSpeechResources(speechConfig: SpeechSDK.SpeechConfig, synthesizer:
382
482
 
383
483
  const ticksToMilliseconds = (ticks: number): number => Math.max(0, ticks) / 10_000;
384
484
 
385
- export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<SsmlSynthesisResult> {
485
+ async function synthesizeSsmlOnce(ssml: string, config: TtsConfig): Promise<SsmlSynthesisResult> {
386
486
  if (config.signal?.aborted) {
387
487
  throw new SynthesisCancelledError();
388
488
  }
@@ -540,6 +640,13 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
540
640
  rejectWithError(err);
541
641
  return;
542
642
  }
643
+ let audioSpec: AudioSpecification;
644
+ try {
645
+ audioSpec = inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT);
646
+ } catch (error) {
647
+ rejectWithError(error);
648
+ return;
649
+ }
543
650
  settled = true;
544
651
  cleanup();
545
652
  closeResources();
@@ -579,8 +686,8 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
579
686
  resolve({
580
687
  audioData: result.audioData,
581
688
  durationMs,
582
- audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
583
- mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
689
+ audioSpec,
690
+ mimeType: config.outputMimeType ?? resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
584
691
  ...(config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {}),
585
692
  ...(requestId ? { requestId } : {}),
586
693
  ...(sourceBoundaries.length > 0
@@ -596,7 +703,7 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
596
703
  abortHandler = () => rejectWithError(new SynthesisCancelledError());
597
704
  config.signal.addEventListener("abort", abortHandler, { once: true });
598
705
  }
599
- const timeoutMs = config.timeouts?.perChunkMs ?? config.timeoutMs;
706
+ const timeoutMs = config.timeouts?.perChunkMs ?? config.timeouts?.totalJobMs ?? config.timeoutMs;
600
707
  if (timeoutMs !== undefined && timeoutMs > 0) {
601
708
  timeout = setTimeout(
602
709
  () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
@@ -659,6 +766,7 @@ async function synthesizeWithRetry(
659
766
  config: TtsConfig,
660
767
  retryOptions: RetryOptions | undefined,
661
768
  onRetry: (retryAttempt: number, nextRetryDelayMs: number) => void,
769
+ deadlineAtMs?: number,
662
770
  ): Promise<SsmlSynthesisResult> {
663
771
  const options = retryOptions
664
772
  ? {
@@ -672,7 +780,7 @@ async function synthesizeWithRetry(
672
780
  while (true) {
673
781
  if (config.signal?.aborted) throw new SynthesisCancelledError();
674
782
  try {
675
- return await synthesizeSsml(ssml, config);
783
+ return await synthesizeSsmlOnce(ssml, config);
676
784
  } catch (error) {
677
785
  if (
678
786
  !options ||
@@ -682,12 +790,31 @@ async function synthesizeWithRetry(
682
790
  throw error;
683
791
  attempt += 1;
684
792
  const delayMs = retryDelay(options, attempt, error);
793
+ const remainingMs = deadlineAtMs === undefined ? undefined : Math.max(0, deadlineAtMs - Date.now());
794
+ if (
795
+ getRetryAfterDelayMs(error) !== undefined &&
796
+ (delayMs > options.maxDelayMs || (remainingMs !== undefined && delayMs > remainingMs))
797
+ ) {
798
+ throw new SynthesisTimeoutError(
799
+ remainingMs === undefined
800
+ ? `Retry-After exceeded maxDelayMs (${options.maxDelayMs} ms).`
801
+ : `Retry-After exceeded the remaining total job timeout (${remainingMs} ms).`,
802
+ );
803
+ }
685
804
  onRetry(attempt, delayMs);
686
- await waitForRetry(delayMs, config.signal);
805
+ await waitForRetry(Math.min(delayMs, remainingMs ?? delayMs), config.signal);
687
806
  }
688
807
  }
689
808
  }
690
809
 
810
+ /** Synthesizes one SSML document, optionally retrying transient failures within the job deadline. */
811
+ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<SsmlSynthesisResult> {
812
+ const totalJobMs = config.timeouts?.totalJobMs;
813
+ const deadlineAtMs = totalJobMs !== undefined && totalJobMs > 0 ? Date.now() + totalJobMs : undefined;
814
+ if (!config.retryOptions) return synthesizeSsmlOnce(ssml, config);
815
+ return synthesizeWithRetry(ssml, config, config.retryOptions, () => undefined, deadlineAtMs);
816
+ }
817
+
691
818
  interface AbortScope {
692
819
  signal: AbortSignal;
693
820
  timedOut: () => boolean;
@@ -725,10 +852,11 @@ async function synthesizeChunkWithTimeout(
725
852
  retryOptions: RetryOptions | undefined,
726
853
  timeoutMs: number | undefined,
727
854
  onRetry: (retryAttempt: number, nextRetryDelayMs: number) => void,
855
+ deadlineAtMs?: number,
728
856
  ): Promise<SsmlSynthesisResult> {
729
857
  const scope = createAbortScope(config.signal, timeoutMs);
730
858
  try {
731
- return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry);
859
+ return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry, deadlineAtMs);
732
860
  } catch (error) {
733
861
  if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
734
862
  throw error;
@@ -742,21 +870,43 @@ export async function synthesizeSsmlChunks(
742
870
  chunks: readonly (SsmlSynthesisChunk | string)[],
743
871
  config: TtsConfig,
744
872
  ): Promise<SsmlSynthesisResult> {
745
- const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
746
873
  const totalChunks = chunks.length;
874
+ const inputs = chunks.map((chunk) => (typeof chunk === "string" ? { ssml: chunk } : chunk));
875
+ const fingerprints = inputs.map((chunk) =>
876
+ computeChunkFingerprint(chunk.ssml, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
877
+ );
878
+ const results: Array<SsmlSynthesisResult | undefined> = new Array(totalChunks);
747
879
  const cachedChunks = new Map((config.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
880
+ const invalidCachedIndices = new Set<number>();
881
+ const chunkStates: ChunkExecutionState[] = inputs.map((_chunk, chunkIndex) => ({
882
+ chunkIndex,
883
+ status: "pending",
884
+ canResume: true,
885
+ }));
748
886
  for (const [index, cached] of cachedChunks) {
749
- if (index >= 0 && index < totalChunks) results[index] = cached;
887
+ if (index < 0 || index >= totalChunks) continue;
888
+ const isValid = config.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index];
889
+ if (isValid) {
890
+ results[index] = { ...cached };
891
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
892
+ } else {
893
+ invalidCachedIndices.add(index);
894
+ }
750
895
  }
751
896
  const requestedIndices = config.resumeChunkIndices
752
897
  ? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks))
753
898
  : undefined;
754
899
  const shouldSynthesize = (index: number): boolean =>
755
- !cachedChunks.has(index) && (requestedIndices === undefined || requestedIndices.has(index));
900
+ (!cachedChunks.has(index) || invalidCachedIndices.has(index)) &&
901
+ (requestedIndices === undefined || requestedIndices.has(index) || invalidCachedIndices.has(index));
902
+ const jobStartedAt = Date.now();
903
+ const jobDeadlineAt =
904
+ config.timeouts?.totalJobMs !== undefined && config.timeouts.totalJobMs > 0
905
+ ? jobStartedAt + config.timeouts.totalJobMs
906
+ : undefined;
756
907
  const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
757
908
  const report = (event: SynthesisProgressEvent): void => config.onProgress?.(event);
758
- for (const [index, chunk] of chunks.entries()) {
759
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
909
+ for (const [index, input] of inputs.entries()) {
760
910
  report({
761
911
  currentChunk: index,
762
912
  totalChunks,
@@ -778,8 +928,7 @@ export async function synthesizeSsmlChunks(
778
928
  if (index >= chunks.length) return;
779
929
  if (!shouldSynthesize(index)) continue;
780
930
  if (firstError && config.cancelOnFailure !== false) return;
781
- const chunk = chunks[index];
782
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
931
+ const input = inputs[index];
783
932
  report({
784
933
  currentChunk: completed,
785
934
  totalChunks,
@@ -820,8 +969,10 @@ export async function synthesizeSsmlChunks(
820
969
  nextRetryDelayMs,
821
970
  isRetrying: true,
822
971
  }),
972
+ jobDeadlineAt,
823
973
  );
824
974
  results[index] = result;
975
+ chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result };
825
976
  completed += 1;
826
977
  report({
827
978
  currentChunk: completed,
@@ -833,7 +984,18 @@ export async function synthesizeSsmlChunks(
833
984
  durationMs: Date.now() - startedAt,
834
985
  });
835
986
  } catch (error) {
836
- failedIndices.add(index);
987
+ const wasCancelled = firstError !== undefined || (scope.signal.aborted && !scope.timedOut());
988
+ firstError ??= scope.timedOut()
989
+ ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`)
990
+ : error;
991
+ if (!wasCancelled) failedIndices.add(index);
992
+ chunkStates[index] = {
993
+ chunkIndex: index,
994
+ status: wasCancelled ? "cancelled" : "failed",
995
+ isOriginalFailure: !wasCancelled,
996
+ canResume: true,
997
+ error: error as ChunkExecutionState["error"],
998
+ };
837
999
  report({
838
1000
  currentChunk: completed,
839
1001
  totalChunks,
@@ -844,9 +1006,6 @@ export async function synthesizeSsmlChunks(
844
1006
  durationMs: Date.now() - startedAt,
845
1007
  error,
846
1008
  });
847
- firstError ??= scope.timedOut()
848
- ? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`)
849
- : error;
850
1009
  if (config.cancelOnFailure !== false) scope.abort();
851
1010
  return;
852
1011
  }
@@ -864,11 +1023,29 @@ export async function synthesizeSsmlChunks(
864
1023
  postMergeValidator: config.postMergeValidator,
865
1024
  });
866
1025
  } catch (error) {
1026
+ if (firstError && config.cancelOnFailure !== false) {
1027
+ for (const [chunkIndex, state] of chunkStates.entries()) {
1028
+ if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
1029
+ chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
1030
+ }
1031
+ }
1032
+ }
1033
+ const synthesizedChunks = results.flatMap((result, chunkIndex) =>
1034
+ result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : [],
1035
+ );
867
1036
  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])),
1037
+ synthesizedChunks,
1038
+ completedChunks: synthesizedChunks,
1039
+ pendingChunkIndices: chunkStates.flatMap((state) =>
1040
+ state.status === "pending" || state.status === "cancelled" || state.status === "failed"
1041
+ ? [state.chunkIndex]
1042
+ : [],
1043
+ ),
871
1044
  failedChunkIndices: [...failedIndices],
1045
+ cancelledChunkIndices: chunkStates
1046
+ .filter((state) => state.status === "cancelled")
1047
+ .map((state) => state.chunkIndex),
1048
+ chunkStates,
872
1049
  totalChunks,
873
1050
  };
874
1051
  if (error && typeof error === "object") (error as { partialResult?: unknown }).partialResult = partial;
@@ -998,19 +1175,15 @@ export function mergeSynthesisResults(
998
1175
  )
999
1176
  .then((merged) => {
1000
1177
  if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
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
1178
  if (signal.aborted) throw new SynthesisCancelledError();
1007
- const result = createMergedResult(
1008
- results,
1179
+ const mergedSpec = validateMergedAudioBuffer(
1009
1180
  merged,
1010
1181
  format,
1011
- inspectAudioSpecification(merged, format),
1012
- resolvedOptions.outputMimeType,
1182
+ buffers,
1183
+ inputSpecs,
1184
+ resolvedOptions.outputMimeType ?? resolveMimeType(format),
1013
1185
  );
1186
+ const result = createMergedResult(results, merged, format, mergedSpec, resolvedOptions.outputMimeType);
1014
1187
  return Promise.resolve(
1015
1188
  resolvedOptions.postMergeValidator?.(result, {
1016
1189
  format,
package/src/types.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { SsmlSourceMarker, SsmlSourceTextSegment, SsmlTextRange } from "@ssml-builder-js/ssml-core";
2
2
  import type { AzureTtsOutputFormat } from "./outputFormats.ts";
3
+ import type { AzureTtsError } from "./errors.ts";
4
+ import type { SsmlValidationError } from "./safe.ts";
3
5
 
4
6
  export interface TtsConfig {
5
7
  signal?: AbortSignal;
@@ -27,6 +29,7 @@ export interface TtsConfig {
27
29
  customMerger?: CustomAudioMerger;
28
30
  outputMimeType?: string;
29
31
  postMergeValidator?: PostMergeValidator;
32
+ resumeValidation?: ResumeValidationMode;
30
33
  }
31
34
 
32
35
  export type MappingStatus = "exact" | "fallback" | "unmapped";
@@ -34,7 +37,7 @@ export type MappingStatus = "exact" | "fallback" | "unmapped";
34
37
  export interface AudioSpecification {
35
38
  format: string;
36
39
  mimeType: string;
37
- codec: "pcm" | "mp3" | "opus" | "silk" | "unknown";
40
+ codec: "pcm" | "mulaw" | "alaw" | "siren" | "mp3" | "opus" | "silk" | "unknown";
38
41
  sampleRate: number;
39
42
  channels: number;
40
43
  bitrate?: number;
@@ -44,6 +47,8 @@ export interface AudioSpecification {
44
47
  isCompressed: boolean;
45
48
  }
46
49
 
50
+ export type ResumeValidationMode = "strict" | "disabled";
51
+
47
52
  export interface SynthesisTimeouts {
48
53
  urlValidationMs?: number;
49
54
  perChunkMs?: number;
@@ -148,6 +153,7 @@ export interface SynthesizeChunksOptions {
148
153
  customMerger?: CustomAudioMerger;
149
154
  outputMimeType?: string;
150
155
  postMergeValidator?: PostMergeValidator;
156
+ resumeValidation?: ResumeValidationMode;
151
157
  }
152
158
 
153
159
  export interface CustomMergerContext {
@@ -169,6 +175,19 @@ export type PostMergeValidator = (
169
175
 
170
176
  export interface SynthesizedChunk extends SsmlSynthesisResult {
171
177
  chunkIndex: number;
178
+ /** Fingerprint of the SSML and synthesis settings used to create this chunk. */
179
+ fingerprint: string;
180
+ }
181
+
182
+ export type ChunkExecutionStatus = "succeeded" | "failed" | "cancelled" | "pending";
183
+
184
+ export interface ChunkExecutionState {
185
+ chunkIndex: number;
186
+ status: ChunkExecutionStatus;
187
+ error?: AzureTtsError | SsmlValidationError;
188
+ isOriginalFailure?: boolean;
189
+ canResume: boolean;
190
+ result?: SsmlSynthesisResult;
172
191
  }
173
192
 
174
193
  export interface PartialChunkSynthesisResult {
@@ -176,6 +195,8 @@ export interface PartialChunkSynthesisResult {
176
195
  completedChunks: readonly SynthesizedChunk[];
177
196
  pendingChunkIndices: readonly number[];
178
197
  failedChunkIndices: readonly number[];
198
+ cancelledChunkIndices: readonly number[];
199
+ chunkStates: readonly ChunkExecutionState[];
179
200
  totalChunks: number;
180
201
  }
181
202
 
@@ -216,4 +237,9 @@ export interface AzureTtsClientOptions {
216
237
  onProgress?: (event: SynthesisProgressEvent) => void;
217
238
  concurrency?: number;
218
239
  retryOptions?: RetryOptions;
240
+ cancelOnFailure?: boolean;
241
+ customMerger?: CustomAudioMerger;
242
+ outputMimeType?: string;
243
+ postMergeValidator?: PostMergeValidator;
244
+ resumeValidation?: ResumeValidationMode;
219
245
  }
@@ -0,0 +1,91 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { AzureTtsError, inspectAudioSpecification, synthesizeSsmlChunksSafe } from "../src/index.ts";
4
+
5
+ const validSsml = (text: string) =>
6
+ `<speak version="1.0" xml:lang="en-US"><voice name="en-US-JennyNeural"><prosody rate="+5%" pitch="+2st">${text}</prosody></voice></speak>`;
7
+
8
+ test("invalidates a resume chunk when its SSML fingerprint changes", async () => {
9
+ let calls = 0;
10
+ let failSecondChunk = true;
11
+ const client = {
12
+ synthesizeSsml: async (ssml: string) => {
13
+ calls += 1;
14
+ if (ssml.includes("two") && failSecondChunk) {
15
+ failSecondChunk = false;
16
+ throw new Error("temporary failure");
17
+ }
18
+ return { audioData: Uint8Array.of(calls).buffer, durationMs: 1 };
19
+ },
20
+ };
21
+ const first = await synthesizeSsmlChunksSafe(client, [validSsml("one"), validSsml("two")], {
22
+ concurrency: 1,
23
+ });
24
+ assert.equal(first.ok, false);
25
+ assert.ok(first.partialResult);
26
+ if (first.ok || !first.partialResult) return;
27
+
28
+ const resumed = await synthesizeSsmlChunksSafe(client, [validSsml("changed"), validSsml("two")], {
29
+ concurrency: 1,
30
+ resumeChunks: first.partialResult.synthesizedChunks,
31
+ resumeChunkIndices: first.partialResult.pendingChunkIndices,
32
+ });
33
+ assert.equal(resumed.ok, true);
34
+ assert.equal(calls, 4);
35
+ assert.match(first.partialResult.synthesizedChunks[0]?.fingerprint ?? "", /^fnv1a64-/);
36
+ });
37
+
38
+ test("separates the original failure from chained cancellations", async () => {
39
+ const result = await synthesizeSsmlChunksSafe(
40
+ {
41
+ synthesizeSsml: async () => {
42
+ throw new Error("direct failure");
43
+ },
44
+ },
45
+ [validSsml("fail"), validSsml("cancelled")],
46
+ { concurrency: 1 },
47
+ );
48
+ assert.equal(result.ok, false);
49
+ if (!result.ok) {
50
+ assert.deepEqual(result.partialResult?.failedChunkIndices, [0]);
51
+ assert.deepEqual(result.partialResult?.cancelledChunkIndices, [1]);
52
+ assert.equal(result.partialResult?.chunkStates[0]?.status, "failed");
53
+ assert.equal(result.partialResult?.chunkStates[0]?.isOriginalFailure, true);
54
+ assert.equal(result.partialResult?.chunkStates[1]?.status, "cancelled");
55
+ assert.equal(result.partialResult?.chunkStates[1]?.isOriginalFailure, false);
56
+ }
57
+ });
58
+
59
+ test("does not wait for Retry-After beyond the retry budget", async () => {
60
+ let calls = 0;
61
+ const startedAt = Date.now();
62
+ const result = await synthesizeSsmlChunksSafe(
63
+ {
64
+ synthesizeSsml: async () => {
65
+ calls += 1;
66
+ throw new AzureTtsError(429, "Too Many Requests", "", null, { "retry-after": "10" });
67
+ },
68
+ },
69
+ [validSsml("retry")],
70
+ { retryOptions: { maxRetries: 2, initialDelayMs: 1, maxDelayMs: 5 } },
71
+ );
72
+ assert.equal(result.ok, false);
73
+ assert.equal(calls, 1);
74
+ assert.ok(Date.now() - startedAt < 100);
75
+ if (!result.ok) assert.equal(result.error.kind, "timeout");
76
+ });
77
+
78
+ test("maps headerless RAW formats to strict codec specifications", () => {
79
+ const mulaw = inspectAudioSpecification(new ArrayBuffer(8), "raw-8khz-8bit-mono-mulaw");
80
+ assert.deepEqual(
81
+ {
82
+ sampleRate: mulaw.sampleRate,
83
+ channels: mulaw.channels,
84
+ bitDepth: mulaw.bitDepth,
85
+ codec: mulaw.codec,
86
+ mimeType: mulaw.mimeType,
87
+ },
88
+ { sampleRate: 8_000, channels: 1, bitDepth: 8, codec: "mulaw", mimeType: "audio/basic" },
89
+ );
90
+ assert.throws(() => inspectAudioSpecification(new ArrayBuffer(1), "raw-16khz-16bit-mono-pcm"));
91
+ });