@ssml-builder-js/azure-tts-client 2.16.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/types.ts CHANGED
@@ -1,9 +1,12 @@
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;
6
8
  timeoutMs?: number;
9
+ timeouts?: SynthesisTimeouts;
7
10
  endpoint?: string;
8
11
  subscriptionKey: string;
9
12
  region: string;
@@ -20,6 +23,13 @@ export interface TtsConfig {
20
23
  sourceMarkers?: SsmlSourceMarker[];
21
24
  concurrency?: number;
22
25
  retryOptions?: RetryOptions;
26
+ cancelOnFailure?: boolean;
27
+ resumeChunks?: readonly SynthesizedChunk[];
28
+ resumeChunkIndices?: readonly number[];
29
+ customMerger?: CustomAudioMerger;
30
+ outputMimeType?: string;
31
+ postMergeValidator?: PostMergeValidator;
32
+ resumeValidation?: ResumeValidationMode;
23
33
  }
24
34
 
25
35
  export type MappingStatus = "exact" | "fallback" | "unmapped";
@@ -27,17 +37,30 @@ export type MappingStatus = "exact" | "fallback" | "unmapped";
27
37
  export interface AudioSpecification {
28
38
  format: string;
29
39
  mimeType: string;
30
- codec: "pcm" | "mp3" | "opus" | "silk" | "unknown";
40
+ codec: "pcm" | "mulaw" | "alaw" | "siren" | "mp3" | "opus" | "silk" | "unknown";
31
41
  sampleRate: number;
32
42
  channels: number;
33
43
  bitrate?: number;
44
+ bitDepth?: number;
45
+ container?: string;
46
+ isVbr?: boolean;
34
47
  isCompressed: boolean;
35
48
  }
36
49
 
50
+ export type ResumeValidationMode = "strict" | "disabled";
51
+
52
+ export interface SynthesisTimeouts {
53
+ urlValidationMs?: number;
54
+ perChunkMs?: number;
55
+ chunkWithRetriesMs?: number;
56
+ totalJobMs?: number;
57
+ }
58
+
37
59
  export interface RetryOptions {
38
60
  maxRetries: number;
39
61
  initialDelayMs: number;
40
62
  maxDelayMs: number;
63
+ shouldRetry?: (error: unknown, attempt: number) => boolean;
41
64
  }
42
65
 
43
66
  export type SynthesisChunkStatus = "pending" | "synthesizing" | "success" | "failed";
@@ -120,11 +143,66 @@ export interface SynthesizeChunksOptions {
120
143
  outputFormat?: AzureTtsOutputFormat | string;
121
144
  signal?: AbortSignal;
122
145
  timeoutMs?: number;
146
+ timeouts?: SynthesisTimeouts;
123
147
  sourceNodePath?: string[];
124
148
  concurrency?: number;
125
149
  retryOptions?: RetryOptions;
150
+ cancelOnFailure?: boolean;
151
+ resumeChunks?: readonly SynthesizedChunk[];
152
+ resumeChunkIndices?: readonly number[];
153
+ customMerger?: CustomAudioMerger;
154
+ outputMimeType?: string;
155
+ postMergeValidator?: PostMergeValidator;
156
+ resumeValidation?: ResumeValidationMode;
157
+ }
158
+
159
+ export interface CustomMergerContext {
160
+ format: string;
161
+ outputMimeType: string;
162
+ inputSpecs: readonly AudioSpecification[];
163
+ signal: AbortSignal;
126
164
  }
127
165
 
166
+ export type CustomAudioMerger = (
167
+ buffers: ArrayBuffer[],
168
+ context: CustomMergerContext,
169
+ ) => Promise<ArrayBuffer> | ArrayBuffer;
170
+
171
+ export type PostMergeValidator = (
172
+ result: MergedSynthesisResult,
173
+ context: CustomMergerContext,
174
+ ) => boolean | undefined | Promise<boolean | undefined>;
175
+
176
+ export interface SynthesizedChunk extends SsmlSynthesisResult {
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;
191
+ }
192
+
193
+ export interface PartialChunkSynthesisResult {
194
+ synthesizedChunks: readonly SynthesizedChunk[];
195
+ completedChunks: readonly SynthesizedChunk[];
196
+ pendingChunkIndices: readonly number[];
197
+ failedChunkIndices: readonly number[];
198
+ cancelledChunkIndices: readonly number[];
199
+ chunkStates: readonly ChunkExecutionState[];
200
+ totalChunks: number;
201
+ }
202
+
203
+ /** Alias for applications that use the shorter result name. */
204
+ export type PartialSynthesisResult = PartialChunkSynthesisResult;
205
+
128
206
  export interface SynthesisProgressEvent {
129
207
  /** 1-based completed chunk count retained for backward compatibility. */
130
208
  currentChunk: number;
@@ -150,6 +228,7 @@ export interface AzureTtsLogger {
150
228
  export interface AzureTtsClientOptions {
151
229
  signal?: AbortSignal;
152
230
  timeoutMs?: number;
231
+ timeouts?: SynthesisTimeouts;
153
232
  subscriptionKey: string;
154
233
  region: string;
155
234
  endpoint?: string;
@@ -158,4 +237,9 @@ export interface AzureTtsClientOptions {
158
237
  onProgress?: (event: SynthesisProgressEvent) => void;
159
238
  concurrency?: number;
160
239
  retryOptions?: RetryOptions;
240
+ cancelOnFailure?: boolean;
241
+ customMerger?: CustomAudioMerger;
242
+ outputMimeType?: string;
243
+ postMergeValidator?: PostMergeValidator;
244
+ resumeValidation?: ResumeValidationMode;
161
245
  }
@@ -22,6 +22,8 @@ export interface FetchedAzureVoiceCatalogMetadata {
22
22
  generatedAt: string;
23
23
  apiVersion: string;
24
24
  regions: readonly string[];
25
+ expiresAt?: string;
26
+ regionDiffs?: Readonly<Record<string, readonly string[]>>;
25
27
  }
26
28
 
27
29
  export interface AzureVoiceCatalog {
@@ -130,6 +132,8 @@ export async function fetchAzureVoiceCatalog(options: FetchAzureVoiceCatalogOpti
130
132
  generatedAt: new Date().toISOString(),
131
133
  apiVersion: AZURE_VOICE_API_VERSION,
132
134
  regions,
135
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
136
+ regionDiffs: {},
133
137
  },
134
138
  };
135
139
  }
@@ -0,0 +1,125 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ AzureTtsError,
5
+ BatchChunkValidationError,
6
+ getRetryAfterDelayMs,
7
+ synthesizeSsmlChunksSafe,
8
+ } from "../src/index.ts";
9
+
10
+ const validSsml = (text: string) =>
11
+ `<speak version="1.0" xml:lang="en-US"><voice name="en-US-JennyNeural">${text}</voice></speak>`;
12
+
13
+ test("aggregates every invalid chunk and every diagnostic before synthesis", async () => {
14
+ let calls = 0;
15
+ const result = await synthesizeSsmlChunksSafe(
16
+ {
17
+ synthesizeSsml: async () => {
18
+ calls += 1;
19
+ return { audioData: new ArrayBuffer(0), durationMs: 0 };
20
+ },
21
+ },
22
+ ["<speak>", "<speak>", validSsml("ok")],
23
+ );
24
+
25
+ assert.equal(result.ok, false);
26
+ assert.equal(calls, 0);
27
+ if (!result.ok) {
28
+ assert.ok(result.error instanceof BatchChunkValidationError);
29
+ assert.deepEqual(
30
+ result.error.chunkDiagnostics.map(({ chunkIndex }) => chunkIndex),
31
+ [0, 1],
32
+ );
33
+ assert.equal(result.error.totalErrorCount, 2);
34
+ }
35
+ });
36
+
37
+ test("custom merger and post-merge validation are part of safe chunk synthesis", async () => {
38
+ let validated = false;
39
+ const result = await synthesizeSsmlChunksSafe(
40
+ {
41
+ synthesizeSsml: async (ssml) => ({
42
+ audioData: Uint8Array.of(ssml.includes("one") ? 1 : 2).buffer,
43
+ durationMs: 10,
44
+ }),
45
+ },
46
+ [validSsml("one"), validSsml("two")],
47
+ {
48
+ concurrency: 2,
49
+ outputMimeType: "audio/custom",
50
+ customMerger: (buffers) => {
51
+ const output = new Uint8Array(buffers.reduce((total, buffer) => total + buffer.byteLength, 0));
52
+ let offset = 0;
53
+ for (const buffer of buffers) {
54
+ output.set(new Uint8Array(buffer), offset);
55
+ offset += buffer.byteLength;
56
+ }
57
+ return output.buffer;
58
+ },
59
+ postMergeValidator: (merged) => {
60
+ validated = merged.mimeType === "audio/custom";
61
+ },
62
+ },
63
+ );
64
+
65
+ assert.equal(result.ok, true);
66
+ assert.equal(validated, true);
67
+ if (result.ok) assert.equal(result.value.mimeType, "audio/custom");
68
+ });
69
+
70
+ test("cancels remaining work and resumes from the partial chunk cache", async () => {
71
+ let calls = 0;
72
+ let failFirstAttempt = true;
73
+ const client = {
74
+ synthesizeSsml: async (ssml: string, options?: { signal?: AbortSignal }) => {
75
+ calls += 1;
76
+ if (ssml.includes("fail") && failFirstAttempt) {
77
+ failFirstAttempt = false;
78
+ throw new AzureTtsError(503, "Unavailable", "", null);
79
+ }
80
+ await new Promise<void>((resolve, reject) => {
81
+ const timer = setTimeout(resolve, ssml.includes("one") ? 1 : 50);
82
+ options?.signal?.addEventListener(
83
+ "abort",
84
+ () => {
85
+ clearTimeout(timer);
86
+ reject(new Error("aborted"));
87
+ },
88
+ { once: true },
89
+ );
90
+ });
91
+ return { audioData: Uint8Array.of(calls).buffer, durationMs: 10 };
92
+ },
93
+ };
94
+ const chunks = [validSsml("one"), validSsml("fail"), validSsml("three")];
95
+ const first = await synthesizeSsmlChunksSafe(client, chunks, { concurrency: 2 });
96
+ assert.equal(first.ok, false);
97
+ assert.ok(first.partialResult);
98
+ if (!first.ok && first.partialResult) {
99
+ const resumed = await synthesizeSsmlChunksSafe(client, chunks, {
100
+ concurrency: 2,
101
+ resumeChunks: first.partialResult.synthesizedChunks,
102
+ resumeChunkIndices: first.partialResult.pendingChunkIndices,
103
+ });
104
+ assert.equal(resumed.ok, true);
105
+ }
106
+ assert.ok(calls < 6);
107
+ });
108
+
109
+ test("prioritizes Retry-After and supports structured per-chunk timeouts", async () => {
110
+ const retryAfter = new AzureTtsError(429, "Too Many Requests", "", null, { "retry-after": "2" });
111
+ assert.equal(getRetryAfterDelayMs(retryAfter), 2_000);
112
+
113
+ const result = await synthesizeSsmlChunksSafe(
114
+ {
115
+ synthesizeSsml: async (_ssml, options) =>
116
+ new Promise((_resolve, reject) =>
117
+ options?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }),
118
+ ),
119
+ },
120
+ [validSsml("slow")],
121
+ { timeouts: { perChunkMs: 5 } },
122
+ );
123
+ assert.equal(result.ok, false);
124
+ if (!result.ok) assert.equal(result.error.kind, "timeout");
125
+ });
@@ -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
+ });