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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/types.ts CHANGED
@@ -4,6 +4,7 @@ import type { AzureTtsOutputFormat } from "./outputFormats.ts";
4
4
  export interface TtsConfig {
5
5
  signal?: AbortSignal;
6
6
  timeoutMs?: number;
7
+ timeouts?: SynthesisTimeouts;
7
8
  endpoint?: string;
8
9
  subscriptionKey: string;
9
10
  region: string;
@@ -18,6 +19,43 @@ export interface TtsConfig {
18
19
  /** Exact source text segments used to map individual Azure events. */
19
20
  sourceTextSegments?: SsmlSourceTextSegment[];
20
21
  sourceMarkers?: SsmlSourceMarker[];
22
+ concurrency?: number;
23
+ retryOptions?: RetryOptions;
24
+ cancelOnFailure?: boolean;
25
+ resumeChunks?: readonly SynthesizedChunk[];
26
+ resumeChunkIndices?: readonly number[];
27
+ customMerger?: CustomAudioMerger;
28
+ outputMimeType?: string;
29
+ postMergeValidator?: PostMergeValidator;
30
+ }
31
+
32
+ export type MappingStatus = "exact" | "fallback" | "unmapped";
33
+
34
+ export interface AudioSpecification {
35
+ format: string;
36
+ mimeType: string;
37
+ codec: "pcm" | "mp3" | "opus" | "silk" | "unknown";
38
+ sampleRate: number;
39
+ channels: number;
40
+ bitrate?: number;
41
+ bitDepth?: number;
42
+ container?: string;
43
+ isVbr?: boolean;
44
+ isCompressed: boolean;
45
+ }
46
+
47
+ export interface SynthesisTimeouts {
48
+ urlValidationMs?: number;
49
+ perChunkMs?: number;
50
+ chunkWithRetriesMs?: number;
51
+ totalJobMs?: number;
52
+ }
53
+
54
+ export interface RetryOptions {
55
+ maxRetries: number;
56
+ initialDelayMs: number;
57
+ maxDelayMs: number;
58
+ shouldRetry?: (error: unknown, attempt: number) => boolean;
21
59
  }
22
60
 
23
61
  export type SynthesisChunkStatus = "pending" | "synthesizing" | "success" | "failed";
@@ -36,6 +74,7 @@ export interface SsmlSynthesisBoundary {
36
74
  /** Audio offset within the originating chunk before merge. */
37
75
  chunkAudioOffsetMs?: number;
38
76
  requestId?: string;
77
+ mappingStatus: MappingStatus;
39
78
  }
40
79
 
41
80
  export interface SsmlSynthesisViseme {
@@ -47,6 +86,7 @@ export interface SsmlSynthesisViseme {
47
86
  originalTextRange?: SsmlTextRange;
48
87
  chunkAudioOffsetMs?: number;
49
88
  requestId?: string;
89
+ mappingStatus: MappingStatus;
50
90
  }
51
91
 
52
92
  export interface SsmlSynthesisBookmark {
@@ -58,6 +98,7 @@ export interface SsmlSynthesisBookmark {
58
98
  originalTextRange?: SsmlTextRange;
59
99
  chunkAudioOffsetMs?: number;
60
100
  requestId?: string;
101
+ mappingStatus: MappingStatus;
61
102
  }
62
103
 
63
104
  /** Audio and Azure Speech synchronization events emitted for one SSML request. */
@@ -77,6 +118,7 @@ export interface SsmlSynthesisResult {
77
118
  textRange?: { start: number; end: number };
78
119
  /** MIME type of a result produced by an explicit merge operation. */
79
120
  mimeType?: string;
121
+ audioSpec?: AudioSpecification;
80
122
  }
81
123
 
82
124
  export interface MergedSynthesisResult extends SsmlSynthesisResult {
@@ -96,9 +138,50 @@ export interface SynthesizeChunksOptions {
96
138
  outputFormat?: AzureTtsOutputFormat | string;
97
139
  signal?: AbortSignal;
98
140
  timeoutMs?: number;
141
+ timeouts?: SynthesisTimeouts;
99
142
  sourceNodePath?: string[];
143
+ concurrency?: number;
144
+ retryOptions?: RetryOptions;
145
+ cancelOnFailure?: boolean;
146
+ resumeChunks?: readonly SynthesizedChunk[];
147
+ resumeChunkIndices?: readonly number[];
148
+ customMerger?: CustomAudioMerger;
149
+ outputMimeType?: string;
150
+ postMergeValidator?: PostMergeValidator;
151
+ }
152
+
153
+ export interface CustomMergerContext {
154
+ format: string;
155
+ outputMimeType: string;
156
+ inputSpecs: readonly AudioSpecification[];
157
+ signal: AbortSignal;
100
158
  }
101
159
 
160
+ export type CustomAudioMerger = (
161
+ buffers: ArrayBuffer[],
162
+ context: CustomMergerContext,
163
+ ) => Promise<ArrayBuffer> | ArrayBuffer;
164
+
165
+ export type PostMergeValidator = (
166
+ result: MergedSynthesisResult,
167
+ context: CustomMergerContext,
168
+ ) => boolean | undefined | Promise<boolean | undefined>;
169
+
170
+ export interface SynthesizedChunk extends SsmlSynthesisResult {
171
+ chunkIndex: number;
172
+ }
173
+
174
+ export interface PartialChunkSynthesisResult {
175
+ synthesizedChunks: readonly SynthesizedChunk[];
176
+ completedChunks: readonly SynthesizedChunk[];
177
+ pendingChunkIndices: readonly number[];
178
+ failedChunkIndices: readonly number[];
179
+ totalChunks: number;
180
+ }
181
+
182
+ /** Alias for applications that use the shorter result name. */
183
+ export type PartialSynthesisResult = PartialChunkSynthesisResult;
184
+
102
185
  export interface SynthesisProgressEvent {
103
186
  /** 1-based completed chunk count retained for backward compatibility. */
104
187
  currentChunk: number;
@@ -109,6 +192,9 @@ export interface SynthesisProgressEvent {
109
192
  status: SynthesisChunkStatus;
110
193
  durationMs: number;
111
194
  error?: unknown;
195
+ retryAttempt?: number;
196
+ nextRetryDelayMs?: number;
197
+ isRetrying?: boolean;
112
198
  }
113
199
 
114
200
  export interface AzureTtsLogger {
@@ -121,10 +207,13 @@ export interface AzureTtsLogger {
121
207
  export interface AzureTtsClientOptions {
122
208
  signal?: AbortSignal;
123
209
  timeoutMs?: number;
210
+ timeouts?: SynthesisTimeouts;
124
211
  subscriptionKey: string;
125
212
  region: string;
126
213
  endpoint?: string;
127
214
  outputFormat?: string;
128
215
  logger?: AzureTtsLogger;
129
216
  onProgress?: (event: SynthesisProgressEvent) => void;
217
+ concurrency?: number;
218
+ retryOptions?: RetryOptions;
130
219
  }
@@ -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,110 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ AudioFormatMismatchError,
5
+ AzureTtsError,
6
+ mergeAudioBuffers,
7
+ mergeSynthesisResults,
8
+ synthesizeSsmlChunksSafe,
9
+ } from "../src/index.ts";
10
+
11
+ const validSsml = (text: string) =>
12
+ `<speak version="1.0" xml:lang="en-US"><voice name="en-US-JennyNeural">${text}</voice></speak>`;
13
+
14
+ function wav(data: number[], sampleRate: number): ArrayBuffer {
15
+ const output = new Uint8Array(44 + data.length + (data.length & 1));
16
+ const view = new DataView(output.buffer);
17
+ output.set(new TextEncoder().encode("RIFF"), 0);
18
+ output.set(new TextEncoder().encode("WAVEfmt "), 8);
19
+ view.setUint32(4, output.length - 8, true);
20
+ view.setUint32(16, 16, true);
21
+ view.setUint16(20, 1, true);
22
+ view.setUint16(22, 1, true);
23
+ view.setUint32(24, sampleRate, true);
24
+ view.setUint32(28, sampleRate * 2, true);
25
+ view.setUint16(32, 2, true);
26
+ view.setUint16(34, 16, true);
27
+ output.set(new TextEncoder().encode("data"), 36);
28
+ view.setUint32(40, data.length, true);
29
+ output.set(data, 44);
30
+ return output.buffer;
31
+ }
32
+
33
+ test("chunk merging rejects incompatible WAV sample rates", () => {
34
+ assert.throws(
35
+ () => mergeAudioBuffers([wav([1, 2], 16_000), wav([3, 4], 24_000)], { format: "riff-16khz-16bit-mono-pcm" }),
36
+ (error: unknown) => error instanceof AudioFormatMismatchError,
37
+ );
38
+ });
39
+
40
+ test("safe chunk synthesis retries transient errors and preserves chunk order", async () => {
41
+ let attempts = 0;
42
+ let oneAttempts = 0;
43
+ let active = 0;
44
+ let maximumActive = 0;
45
+ const completed: string[] = [];
46
+ const retryEvents: number[] = [];
47
+ const result = await synthesizeSsmlChunksSafe(
48
+ {
49
+ synthesizeSsml: async (ssml) => {
50
+ attempts += 1;
51
+ active += 1;
52
+ maximumActive = Math.max(maximumActive, active);
53
+ await new Promise((resolve) => setTimeout(resolve, ssml.includes("one") ? 5 : 1));
54
+ active -= 1;
55
+ if (ssml.includes("one")) {
56
+ oneAttempts += 1;
57
+ if (oneAttempts < 3) throw new AzureTtsError(503, "Unavailable", "", null);
58
+ }
59
+ completed.push(ssml.includes("one") ? "one" : "two");
60
+ return { audioData: Uint8Array.of(ssml.includes("one") ? 1 : 2).buffer, durationMs: 10 };
61
+ },
62
+ },
63
+ [validSsml("one"), validSsml("two")],
64
+ {
65
+ concurrency: 2,
66
+ retryOptions: { maxRetries: 2, initialDelayMs: 0, maxDelayMs: 0 },
67
+ onProgress: (event) => {
68
+ if (event.isRetrying && event.retryAttempt) retryEvents.push(event.retryAttempt);
69
+ },
70
+ },
71
+ );
72
+ assert.equal(result.ok, true);
73
+ assert.equal(maximumActive, 2);
74
+ assert.deepEqual(retryEvents, [1, 2]);
75
+ assert.equal(attempts, 4);
76
+ if (result.ok) assert.deepEqual([...new Uint8Array(result.value.audioData)], [1, 2]);
77
+ });
78
+
79
+ test("safe chunk synthesis does not retry permanent HTTP errors", async () => {
80
+ let calls = 0;
81
+ const result = await synthesizeSsmlChunksSafe(
82
+ {
83
+ synthesizeSsml: async () => {
84
+ calls += 1;
85
+ throw new AzureTtsError(400, "Bad Request", "", null);
86
+ },
87
+ },
88
+ [validSsml("bad")],
89
+ { retryOptions: { maxRetries: 3, initialDelayMs: 0, maxDelayMs: 0 } },
90
+ );
91
+ assert.equal(result.ok, false);
92
+ assert.equal(calls, 1);
93
+ });
94
+
95
+ test("custom mergers receive input specifications and cancellation", async () => {
96
+ const controller = new AbortController();
97
+ let received: { format: string; inputSpecs: unknown[]; signal: AbortSignal } | undefined;
98
+ const result = await mergeSynthesisResults([{ audioData: Uint8Array.of(1).buffer, durationMs: 1 }], {
99
+ format: "webm-24khz-16bit-mono-opus",
100
+ signal: controller.signal,
101
+ customMerger: (buffers, context) => {
102
+ received = { format: context.format, inputSpecs: context.inputSpecs, signal: context.signal };
103
+ return buffers[0] ?? new ArrayBuffer(0);
104
+ },
105
+ });
106
+ assert.equal(result.mimeType, "audio/webm");
107
+ assert.equal(received?.format, "webm-24khz-16bit-mono-opus");
108
+ assert.equal(received?.inputSpecs.length, 1);
109
+ assert.equal(received?.signal, controller.signal);
110
+ });
@@ -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
+ });