@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/CHANGELOG.md +21 -0
- package/dist/index.d.mts +223 -127
- package/dist/index.d.ts +223 -127
- package/dist/index.js +552 -98
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +549 -98
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +29 -4
- package/src/errors.ts +44 -2
- package/src/index.ts +19 -2
- package/src/outputFormats.ts +3 -0
- package/src/safe.ts +280 -42
- package/src/synthesis.ts +380 -57
- package/src/types.ts +85 -1
- package/src/voiceCatalog.ts +4 -0
- package/test/v217-pipeline.test.ts +125 -0
- package/test/v218-pipeline.test.ts +91 -0
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
|
}
|
package/src/voiceCatalog.ts
CHANGED
|
@@ -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
|
+
});
|