@ssml-builder-js/azure-tts-client 2.18.0 → 2.19.1
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 +18 -0
- package/LICENSE +21 -0
- package/dist/index.js +453 -63
- package/package.json +8 -8
- package/src/client.ts +43 -3
- package/src/deadline.ts +52 -0
- package/src/errors.ts +86 -4
- package/src/index.ts +11 -1
- package/src/safe.ts +52 -31
- package/src/synthesis.ts +307 -22
- package/src/types.ts +8 -3
- package/test/synthesis.test.ts +20 -0
- package/test/v219-pipeline.test.ts +265 -0
- package/dist/index.d.mts +0 -487
- package/dist/index.d.ts +0 -487
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -1813
- package/dist/index.mjs.map +0 -1
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
AudioFormatMismatchError,
|
|
5
|
+
AzureTtsError,
|
|
6
|
+
AzureTtsClient,
|
|
7
|
+
DeadlineController,
|
|
8
|
+
IncompleteChunkSetError,
|
|
9
|
+
inspectAudioSpecification,
|
|
10
|
+
serializeChunkError,
|
|
11
|
+
synthesizeSsmlChunksSafe,
|
|
12
|
+
synthesizeSsmlSafe,
|
|
13
|
+
computeChunkFingerprint,
|
|
14
|
+
} from "../src/index.ts";
|
|
15
|
+
|
|
16
|
+
const validSsml = (text: string) =>
|
|
17
|
+
`<speak version="1.0" xml:lang="en-US"><voice name="en-US-JennyNeural">${text}</voice></speak>`;
|
|
18
|
+
|
|
19
|
+
function ebmlSize(size: number): Uint8Array {
|
|
20
|
+
if (size < 0x7f) return Uint8Array.of(0x80 | size);
|
|
21
|
+
if (size < 0x3fff) return Uint8Array.of(0x40 | (size >> 8), size & 0xff);
|
|
22
|
+
throw new Error("Test fixture is too large.");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ebmlElement(id: readonly number[], data: Uint8Array): Uint8Array {
|
|
26
|
+
return Uint8Array.from([...id, ...ebmlSize(data.byteLength), ...data]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function concatBytes(...parts: Uint8Array[]): Uint8Array {
|
|
30
|
+
return Uint8Array.from(parts.flatMap((part) => [...part]));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function oggOpus(): ArrayBuffer {
|
|
34
|
+
const payload = new Uint8Array(19);
|
|
35
|
+
payload.set(new TextEncoder().encode("OpusHead"));
|
|
36
|
+
payload[8] = 1;
|
|
37
|
+
payload[9] = 1;
|
|
38
|
+
new DataView(payload.buffer).setUint32(12, 16_000, true);
|
|
39
|
+
const page = new Uint8Array(27 + 1 + payload.byteLength);
|
|
40
|
+
page.set(new TextEncoder().encode("OggS"));
|
|
41
|
+
page[26] = 1;
|
|
42
|
+
page[27] = payload.byteLength;
|
|
43
|
+
page.set(payload, 28);
|
|
44
|
+
return page.buffer;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function webmOpus(): ArrayBuffer {
|
|
48
|
+
const sampling = new ArrayBuffer(8);
|
|
49
|
+
new DataView(sampling).setFloat64(0, 24_000, false);
|
|
50
|
+
const audio = ebmlElement(
|
|
51
|
+
[0xe1],
|
|
52
|
+
concatBytes(ebmlElement([0xb5], new Uint8Array(sampling)), ebmlElement([0x9f], Uint8Array.of(1))),
|
|
53
|
+
);
|
|
54
|
+
const track = ebmlElement(
|
|
55
|
+
[0xae],
|
|
56
|
+
Uint8Array.from([
|
|
57
|
+
...ebmlElement([0xd7], Uint8Array.of(1)),
|
|
58
|
+
...ebmlElement([0x83], Uint8Array.of(2)),
|
|
59
|
+
...ebmlElement([0x86], new TextEncoder().encode("A_OPUS")),
|
|
60
|
+
...audio,
|
|
61
|
+
]),
|
|
62
|
+
);
|
|
63
|
+
const tracks = ebmlElement([0x16, 0x54, 0xae, 0x6b], track);
|
|
64
|
+
const ebml = ebmlElement([0x1a, 0x45, 0xdf, 0xa3], ebmlElement([0x42, 0x82], new TextEncoder().encode("webm")));
|
|
65
|
+
const segment = ebmlElement([0x18, 0x53, 0x80, 0x67], tracks);
|
|
66
|
+
return Uint8Array.from([...ebml, ...segment]).buffer;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test("fingerprints include the complete synthesis environment", () => {
|
|
70
|
+
const base = computeChunkFingerprint(validSsml("hello"), "audio-16khz-128kbitrate-mono-mp3", {
|
|
71
|
+
region: "eastus",
|
|
72
|
+
endpoint: "https://eastus.example.test/tts",
|
|
73
|
+
customHeaders: { "x-tenant": "a" },
|
|
74
|
+
fingerprintSchemaVersion: "2",
|
|
75
|
+
});
|
|
76
|
+
assert.notEqual(
|
|
77
|
+
base,
|
|
78
|
+
computeChunkFingerprint(validSsml("hello"), "audio-16khz-128kbitrate-mono-mp3", {
|
|
79
|
+
region: "japaneast",
|
|
80
|
+
endpoint: "https://japaneast.example.test/tts",
|
|
81
|
+
customHeaders: { "x-tenant": "a" },
|
|
82
|
+
fingerprintSchemaVersion: "2",
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
assert.notEqual(
|
|
86
|
+
base,
|
|
87
|
+
computeChunkFingerprint(validSsml("hello"), "audio-16khz-128kbitrate-mono-mp3", {
|
|
88
|
+
region: "eastus",
|
|
89
|
+
endpoint: "https://eastus.example.test/tts",
|
|
90
|
+
customHeaders: { "x-tenant": "b" },
|
|
91
|
+
fingerprintSchemaVersion: "2",
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("fingerprints canonicalize headers and support explicit voice and language inputs", () => {
|
|
97
|
+
const options = {
|
|
98
|
+
region: "eastus",
|
|
99
|
+
endpoint: "https://eastus.example.test/tts",
|
|
100
|
+
customHeaders: { "x-tenant": "a", "x-request": "b" },
|
|
101
|
+
voice: "en-US-JennyNeural",
|
|
102
|
+
lang: "en-US",
|
|
103
|
+
schemaVersion: "legacy",
|
|
104
|
+
} as const;
|
|
105
|
+
assert.equal(
|
|
106
|
+
computeChunkFingerprint(validSsml("hello"), "audio-16khz-128kbitrate-mono-mp3", options),
|
|
107
|
+
computeChunkFingerprint(validSsml("hello"), "audio-16khz-128kbitrate-mono-mp3", {
|
|
108
|
+
...options,
|
|
109
|
+
customHeaders: { "x-request": "b", "x-tenant": "a" },
|
|
110
|
+
fingerprintSchemaVersion: "legacy",
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
assert.notEqual(
|
|
114
|
+
computeChunkFingerprint(validSsml("hello"), "audio-16khz-128kbitrate-mono-mp3", options),
|
|
115
|
+
computeChunkFingerprint(validSsml("hello"), "audio-16khz-128kbitrate-mono-mp3", {
|
|
116
|
+
...options,
|
|
117
|
+
voice: "en-US-AriaNeural",
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("serializes synthesis failures with stable codes and retry metadata", () => {
|
|
123
|
+
const retryable = serializeChunkError(
|
|
124
|
+
new AzureTtsError(503, "Service Unavailable", "", "request-1"),
|
|
125
|
+
"synthesis",
|
|
126
|
+
true,
|
|
127
|
+
);
|
|
128
|
+
assert.deepEqual(retryable, {
|
|
129
|
+
code: "AZURE_API_ERROR",
|
|
130
|
+
phase: "synthesis",
|
|
131
|
+
message: "Azure TTS request failed: 503 Service Unavailable",
|
|
132
|
+
isOriginalFailure: true,
|
|
133
|
+
isRetryable: true,
|
|
134
|
+
httpStatus: 503,
|
|
135
|
+
details: { statusText: "Service Unavailable", requestId: "request-1" },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const incomplete = serializeChunkError(new IncompleteChunkSetError(3, [1, 2]), "merge", false);
|
|
139
|
+
assert.equal(incomplete.code, "MERGE_ERROR");
|
|
140
|
+
assert.equal(incomplete.isRetryable, false);
|
|
141
|
+
assert.deepEqual(incomplete.details, { totalChunks: 3, missingChunkIndices: [1, 2] });
|
|
142
|
+
assert.equal(serializeChunkError(new Error("request aborted"), "synthesis", false).code, "CANCELLED");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("DeadlineController distinguishes cancellation from expiry and cleans up", async () => {
|
|
146
|
+
const cancelled = new DeadlineController(1_000);
|
|
147
|
+
cancelled.abort();
|
|
148
|
+
assert.equal(cancelled.signal.aborted, true);
|
|
149
|
+
assert.throws(() => cancelled.throwIfExpired(), /cancelled/);
|
|
150
|
+
cancelled.dispose();
|
|
151
|
+
|
|
152
|
+
const expired = new DeadlineController(5);
|
|
153
|
+
await new Promise((resolve) => setTimeout(resolve, 15));
|
|
154
|
+
assert.equal(expired.timedOut, true);
|
|
155
|
+
assert.throws(() => expired.throwIfExpired(), /total job deadline/);
|
|
156
|
+
expired.dispose();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("refuses to merge when resumeChunkIndices leave a chunk missing", async () => {
|
|
160
|
+
const fingerprint = computeChunkFingerprint(validSsml("one"));
|
|
161
|
+
const result = await synthesizeSsmlChunksSafe(
|
|
162
|
+
{ synthesizeSsml: async () => ({ audioData: Uint8Array.of(1).buffer, durationMs: 1 }) },
|
|
163
|
+
[validSsml("one"), validSsml("two")],
|
|
164
|
+
{
|
|
165
|
+
resumeChunks: [{ chunkIndex: 0, fingerprint, audioData: Uint8Array.of(1).buffer, durationMs: 1 }],
|
|
166
|
+
resumeChunkIndices: [0],
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
assert.equal(result.ok, false);
|
|
170
|
+
if (!result.ok) assert.ok(result.error instanceof IncompleteChunkSetError);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("applies totalJobMs to one safe synthesis before the client resolves", async () => {
|
|
174
|
+
const result = await synthesizeSsmlSafe(
|
|
175
|
+
{
|
|
176
|
+
synthesizeSsml: async (_ssml, options) =>
|
|
177
|
+
new Promise((resolve, reject) => {
|
|
178
|
+
const timer = setTimeout(() => resolve({ audioData: new ArrayBuffer(0), durationMs: 0 }), 100);
|
|
179
|
+
options?.signal?.addEventListener("abort", () => {
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
reject(new Error("Speech synthesis was cancelled."));
|
|
182
|
+
});
|
|
183
|
+
}),
|
|
184
|
+
},
|
|
185
|
+
validSsml("slow"),
|
|
186
|
+
{ timeouts: { totalJobMs: 10 } },
|
|
187
|
+
);
|
|
188
|
+
assert.equal(result.ok, false);
|
|
189
|
+
if (!result.ok) assert.equal(result.error.kind, "timeout");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("forwards v2.19 synthesis options through the safe chunk client path", async () => {
|
|
193
|
+
let received: Record<string, unknown> | undefined;
|
|
194
|
+
const result = await synthesizeSsmlChunksSafe(
|
|
195
|
+
{
|
|
196
|
+
synthesizeSsml: async () => ({ audioData: Uint8Array.of(1).buffer, durationMs: 1 }),
|
|
197
|
+
synthesizeChunks: async (_chunks, options) => {
|
|
198
|
+
received = options as Record<string, unknown>;
|
|
199
|
+
return { audioData: Uint8Array.of(2).buffer, durationMs: 2 };
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
[validSsml("chunk")],
|
|
203
|
+
{
|
|
204
|
+
customHeaders: { "x-tenant": "a" },
|
|
205
|
+
fingerprintSchemaVersion: "3",
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
assert.equal(result.ok, true);
|
|
209
|
+
assert.deepEqual(received?.customHeaders, { "x-tenant": "a" });
|
|
210
|
+
assert.equal(received?.fingerprintSchemaVersion, "3");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("AzureTtsClient includes default fingerprint settings when resuming chunks", async () => {
|
|
214
|
+
const ssml = validSsml("cached");
|
|
215
|
+
const headers = { "x-tenant": "a" };
|
|
216
|
+
const endpoint = "https://eastus.example.test/tts";
|
|
217
|
+
const cached = {
|
|
218
|
+
chunkIndex: 0,
|
|
219
|
+
fingerprint: computeChunkFingerprint(ssml, undefined, {
|
|
220
|
+
region: "eastus",
|
|
221
|
+
endpoint,
|
|
222
|
+
customHeaders: headers,
|
|
223
|
+
}),
|
|
224
|
+
audioData: new ArrayBuffer(0),
|
|
225
|
+
durationMs: 1,
|
|
226
|
+
};
|
|
227
|
+
const result = await new AzureTtsClient({
|
|
228
|
+
subscriptionKey: "subscription-key",
|
|
229
|
+
region: "eastus",
|
|
230
|
+
endpoint,
|
|
231
|
+
customHeaders: headers,
|
|
232
|
+
}).synthesizeChunks([ssml], { resumeChunks: [cached] });
|
|
233
|
+
|
|
234
|
+
assert.equal(result.durationMs, 1);
|
|
235
|
+
assert.equal(result.audioData.byteLength, 0);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("validates Ogg and WebM codec headers", () => {
|
|
239
|
+
const ogg = inspectAudioSpecification(oggOpus(), "ogg-16khz-16bit-mono-opus");
|
|
240
|
+
assert.deepEqual(
|
|
241
|
+
{ codec: ogg.codec, sampleRate: ogg.sampleRate, channels: ogg.channels, container: ogg.container },
|
|
242
|
+
{ codec: "opus", sampleRate: 16_000, channels: 1, container: "ogg" },
|
|
243
|
+
);
|
|
244
|
+
const webm = inspectAudioSpecification(webmOpus(), "webm-24khz-16bit-mono-opus");
|
|
245
|
+
assert.deepEqual(
|
|
246
|
+
{ codec: webm.codec, sampleRate: webm.sampleRate, channels: webm.channels, mimeType: webm.mimeType },
|
|
247
|
+
{ codec: "opus", sampleRate: 24_000, channels: 1, mimeType: "audio/webm" },
|
|
248
|
+
);
|
|
249
|
+
assert.throws(() =>
|
|
250
|
+
inspectAudioSpecification(Uint8Array.of(0x4f, 0x67, 0x67, 0x53).buffer, "ogg-16khz-16bit-mono-opus"),
|
|
251
|
+
);
|
|
252
|
+
assert.throws(() => inspectAudioSpecification(Uint8Array.of(0x1a, 0x45, 0xdf).buffer, "webm-24khz-16bit-mono-opus"));
|
|
253
|
+
assert.throws(
|
|
254
|
+
() => inspectAudioSpecification(oggOpus(), "ogg-24khz-16bit-mono-opus"),
|
|
255
|
+
(error: unknown) => error instanceof AudioFormatMismatchError,
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("validates RAW SILK and Opus framing headers", () => {
|
|
260
|
+
const silk = Uint8Array.from([...new TextEncoder().encode("#!SILK_V3"), 0x0a]).buffer;
|
|
261
|
+
assert.equal(inspectAudioSpecification(silk, "raw-16khz-16bit-mono-silk").codec, "silk");
|
|
262
|
+
assert.throws(() => inspectAudioSpecification(new ArrayBuffer(10), "raw-16khz-16bit-mono-silk"));
|
|
263
|
+
assert.equal(inspectAudioSpecification(Uint8Array.of(0, 0).buffer, "raw-48khz-16bit-mono-opus").codec, "opus");
|
|
264
|
+
assert.throws(() => inspectAudioSpecification(Uint8Array.of(0x1f, 0).buffer, "raw-48khz-16bit-mono-opus"));
|
|
265
|
+
});
|