@ssml-builder-js/azure-tts-client 2.17.0 → 2.19.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 +20 -0
- package/dist/index.d.mts +123 -43
- package/dist/index.d.ts +123 -43
- package/dist/index.js +674 -90
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +670 -90
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +64 -8
- package/src/deadline.ts +52 -0
- package/src/errors.ts +86 -4
- package/src/index.ts +15 -1
- package/src/outputFormats.ts +3 -0
- package/src/safe.ts +115 -25
- package/src/synthesis.ts +508 -50
- package/src/types.ts +32 -1
- package/test/v218-pipeline.test.ts +91 -0
- package/test/v219-pipeline.test.ts +131 -0
package/src/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SsmlSourceMarker, SsmlSourceTextSegment, SsmlTextRange } from "@ssml-builder-js/ssml-core";
|
|
2
2
|
import type { AzureTtsOutputFormat } from "./outputFormats.ts";
|
|
3
|
+
import type { SerializedChunkError } from "./errors.ts";
|
|
3
4
|
|
|
4
5
|
export interface TtsConfig {
|
|
5
6
|
signal?: AbortSignal;
|
|
@@ -9,6 +10,8 @@ export interface TtsConfig {
|
|
|
9
10
|
subscriptionKey: string;
|
|
10
11
|
region: string;
|
|
11
12
|
outputFormat?: string;
|
|
13
|
+
customHeaders?: Readonly<Record<string, string>>;
|
|
14
|
+
fingerprintSchemaVersion?: string;
|
|
12
15
|
/** Original plain-text range represented by this synthesis request. */
|
|
13
16
|
sourceTextRange?: { start: number; end: number };
|
|
14
17
|
/** Reports chunk lifecycle events when using chunk synthesis. */
|
|
@@ -27,6 +30,7 @@ export interface TtsConfig {
|
|
|
27
30
|
customMerger?: CustomAudioMerger;
|
|
28
31
|
outputMimeType?: string;
|
|
29
32
|
postMergeValidator?: PostMergeValidator;
|
|
33
|
+
resumeValidation?: ResumeValidationMode;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export type MappingStatus = "exact" | "fallback" | "unmapped";
|
|
@@ -34,7 +38,7 @@ export type MappingStatus = "exact" | "fallback" | "unmapped";
|
|
|
34
38
|
export interface AudioSpecification {
|
|
35
39
|
format: string;
|
|
36
40
|
mimeType: string;
|
|
37
|
-
codec: "pcm" | "mp3" | "opus" | "silk" | "unknown";
|
|
41
|
+
codec: "pcm" | "mulaw" | "alaw" | "siren" | "mp3" | "opus" | "silk" | "unknown";
|
|
38
42
|
sampleRate: number;
|
|
39
43
|
channels: number;
|
|
40
44
|
bitrate?: number;
|
|
@@ -44,6 +48,8 @@ export interface AudioSpecification {
|
|
|
44
48
|
isCompressed: boolean;
|
|
45
49
|
}
|
|
46
50
|
|
|
51
|
+
export type ResumeValidationMode = "strict" | "disabled";
|
|
52
|
+
|
|
47
53
|
export interface SynthesisTimeouts {
|
|
48
54
|
urlValidationMs?: number;
|
|
49
55
|
perChunkMs?: number;
|
|
@@ -136,6 +142,8 @@ export interface SsmlSynthesisChunk {
|
|
|
136
142
|
export interface SynthesizeChunksOptions {
|
|
137
143
|
onProgress?: (event: SynthesisProgressEvent) => void;
|
|
138
144
|
outputFormat?: AzureTtsOutputFormat | string;
|
|
145
|
+
customHeaders?: Readonly<Record<string, string>>;
|
|
146
|
+
fingerprintSchemaVersion?: string;
|
|
139
147
|
signal?: AbortSignal;
|
|
140
148
|
timeoutMs?: number;
|
|
141
149
|
timeouts?: SynthesisTimeouts;
|
|
@@ -148,6 +156,7 @@ export interface SynthesizeChunksOptions {
|
|
|
148
156
|
customMerger?: CustomAudioMerger;
|
|
149
157
|
outputMimeType?: string;
|
|
150
158
|
postMergeValidator?: PostMergeValidator;
|
|
159
|
+
resumeValidation?: ResumeValidationMode;
|
|
151
160
|
}
|
|
152
161
|
|
|
153
162
|
export interface CustomMergerContext {
|
|
@@ -169,6 +178,19 @@ export type PostMergeValidator = (
|
|
|
169
178
|
|
|
170
179
|
export interface SynthesizedChunk extends SsmlSynthesisResult {
|
|
171
180
|
chunkIndex: number;
|
|
181
|
+
/** Fingerprint of the SSML and synthesis settings used to create this chunk. */
|
|
182
|
+
fingerprint: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export type ChunkExecutionStatus = "succeeded" | "failed" | "cancelled" | "pending";
|
|
186
|
+
|
|
187
|
+
export interface ChunkExecutionState {
|
|
188
|
+
chunkIndex: number;
|
|
189
|
+
status: ChunkExecutionStatus;
|
|
190
|
+
error?: SerializedChunkError;
|
|
191
|
+
isOriginalFailure?: boolean;
|
|
192
|
+
canResume: boolean;
|
|
193
|
+
result?: SsmlSynthesisResult;
|
|
172
194
|
}
|
|
173
195
|
|
|
174
196
|
export interface PartialChunkSynthesisResult {
|
|
@@ -176,6 +198,8 @@ export interface PartialChunkSynthesisResult {
|
|
|
176
198
|
completedChunks: readonly SynthesizedChunk[];
|
|
177
199
|
pendingChunkIndices: readonly number[];
|
|
178
200
|
failedChunkIndices: readonly number[];
|
|
201
|
+
cancelledChunkIndices: readonly number[];
|
|
202
|
+
chunkStates: readonly ChunkExecutionState[];
|
|
179
203
|
totalChunks: number;
|
|
180
204
|
}
|
|
181
205
|
|
|
@@ -212,8 +236,15 @@ export interface AzureTtsClientOptions {
|
|
|
212
236
|
region: string;
|
|
213
237
|
endpoint?: string;
|
|
214
238
|
outputFormat?: string;
|
|
239
|
+
customHeaders?: Readonly<Record<string, string>>;
|
|
240
|
+
fingerprintSchemaVersion?: string;
|
|
215
241
|
logger?: AzureTtsLogger;
|
|
216
242
|
onProgress?: (event: SynthesisProgressEvent) => void;
|
|
217
243
|
concurrency?: number;
|
|
218
244
|
retryOptions?: RetryOptions;
|
|
245
|
+
cancelOnFailure?: boolean;
|
|
246
|
+
customMerger?: CustomAudioMerger;
|
|
247
|
+
outputMimeType?: string;
|
|
248
|
+
postMergeValidator?: PostMergeValidator;
|
|
249
|
+
resumeValidation?: ResumeValidationMode;
|
|
219
250
|
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
IncompleteChunkSetError,
|
|
5
|
+
inspectAudioSpecification,
|
|
6
|
+
synthesizeSsmlChunksSafe,
|
|
7
|
+
synthesizeSsmlSafe,
|
|
8
|
+
computeChunkFingerprint,
|
|
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 ebmlSize(size: number): Uint8Array {
|
|
15
|
+
if (size < 0x7f) return Uint8Array.of(0x80 | size);
|
|
16
|
+
if (size < 0x3fff) return Uint8Array.of(0x40 | (size >> 8), size & 0xff);
|
|
17
|
+
throw new Error("Test fixture is too large.");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ebmlElement(id: readonly number[], data: Uint8Array): Uint8Array {
|
|
21
|
+
return Uint8Array.from([...id, ...ebmlSize(data.byteLength), ...data]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function concatBytes(...parts: Uint8Array[]): Uint8Array {
|
|
25
|
+
return Uint8Array.from(parts.flatMap((part) => [...part]));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function oggOpus(): ArrayBuffer {
|
|
29
|
+
const payload = new Uint8Array(19);
|
|
30
|
+
payload.set(new TextEncoder().encode("OpusHead"));
|
|
31
|
+
payload[8] = 1;
|
|
32
|
+
payload[9] = 1;
|
|
33
|
+
new DataView(payload.buffer).setUint32(12, 16_000, true);
|
|
34
|
+
const page = new Uint8Array(27 + 1 + payload.byteLength);
|
|
35
|
+
page.set(new TextEncoder().encode("OggS"));
|
|
36
|
+
page[26] = 1;
|
|
37
|
+
page[27] = payload.byteLength;
|
|
38
|
+
page.set(payload, 28);
|
|
39
|
+
return page.buffer;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function webmOpus(): ArrayBuffer {
|
|
43
|
+
const sampling = new ArrayBuffer(8);
|
|
44
|
+
new DataView(sampling).setFloat64(0, 24_000, false);
|
|
45
|
+
const audio = ebmlElement(
|
|
46
|
+
[0xe1],
|
|
47
|
+
concatBytes(ebmlElement([0xb5], new Uint8Array(sampling)), ebmlElement([0x9f], Uint8Array.of(1))),
|
|
48
|
+
);
|
|
49
|
+
const track = ebmlElement(
|
|
50
|
+
[0xae],
|
|
51
|
+
Uint8Array.from([
|
|
52
|
+
...ebmlElement([0xd7], Uint8Array.of(1)),
|
|
53
|
+
...ebmlElement([0x83], Uint8Array.of(2)),
|
|
54
|
+
...ebmlElement([0x86], new TextEncoder().encode("A_OPUS")),
|
|
55
|
+
...audio,
|
|
56
|
+
]),
|
|
57
|
+
);
|
|
58
|
+
const tracks = ebmlElement([0x16, 0x54, 0xae, 0x6b], track);
|
|
59
|
+
const ebml = ebmlElement([0x1a, 0x45, 0xdf, 0xa3], ebmlElement([0x42, 0x82], new TextEncoder().encode("webm")));
|
|
60
|
+
const segment = ebmlElement([0x18, 0x53, 0x80, 0x67], tracks);
|
|
61
|
+
return Uint8Array.from([...ebml, ...segment]).buffer;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test("fingerprints include the complete synthesis environment", () => {
|
|
65
|
+
const base = computeChunkFingerprint(validSsml("hello"), "audio-16khz-128kbitrate-mono-mp3", {
|
|
66
|
+
region: "eastus",
|
|
67
|
+
endpoint: "https://eastus.example.test/tts",
|
|
68
|
+
customHeaders: { "x-tenant": "a" },
|
|
69
|
+
fingerprintSchemaVersion: "2",
|
|
70
|
+
});
|
|
71
|
+
assert.notEqual(
|
|
72
|
+
base,
|
|
73
|
+
computeChunkFingerprint(validSsml("hello"), "audio-16khz-128kbitrate-mono-mp3", {
|
|
74
|
+
region: "japaneast",
|
|
75
|
+
endpoint: "https://japaneast.example.test/tts",
|
|
76
|
+
customHeaders: { "x-tenant": "a" },
|
|
77
|
+
fingerprintSchemaVersion: "2",
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
assert.notEqual(
|
|
81
|
+
base,
|
|
82
|
+
computeChunkFingerprint(validSsml("hello"), "audio-16khz-128kbitrate-mono-mp3", {
|
|
83
|
+
region: "eastus",
|
|
84
|
+
endpoint: "https://eastus.example.test/tts",
|
|
85
|
+
customHeaders: { "x-tenant": "b" },
|
|
86
|
+
fingerprintSchemaVersion: "2",
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("refuses to merge when resumeChunkIndices leave a chunk missing", async () => {
|
|
92
|
+
const fingerprint = computeChunkFingerprint(validSsml("one"));
|
|
93
|
+
const result = await synthesizeSsmlChunksSafe(
|
|
94
|
+
{ synthesizeSsml: async () => ({ audioData: Uint8Array.of(1).buffer, durationMs: 1 }) },
|
|
95
|
+
[validSsml("one"), validSsml("two")],
|
|
96
|
+
{
|
|
97
|
+
resumeChunks: [{ chunkIndex: 0, fingerprint, audioData: Uint8Array.of(1).buffer, durationMs: 1 }],
|
|
98
|
+
resumeChunkIndices: [0],
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
assert.equal(result.ok, false);
|
|
102
|
+
if (!result.ok) assert.ok(result.error instanceof IncompleteChunkSetError);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("applies totalJobMs to one safe synthesis before the client resolves", async () => {
|
|
106
|
+
const result = await synthesizeSsmlSafe(
|
|
107
|
+
{
|
|
108
|
+
synthesizeSsml: async (_ssml, options) =>
|
|
109
|
+
new Promise((resolve, reject) => {
|
|
110
|
+
const timer = setTimeout(() => resolve({ audioData: new ArrayBuffer(0), durationMs: 0 }), 100);
|
|
111
|
+
options?.signal?.addEventListener("abort", () => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
reject(new Error("Speech synthesis was cancelled."));
|
|
114
|
+
});
|
|
115
|
+
}),
|
|
116
|
+
},
|
|
117
|
+
validSsml("slow"),
|
|
118
|
+
{ timeouts: { totalJobMs: 10 } },
|
|
119
|
+
);
|
|
120
|
+
assert.equal(result.ok, false);
|
|
121
|
+
if (!result.ok) assert.equal(result.error.kind, "timeout");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("validates Ogg and WebM codec headers", () => {
|
|
125
|
+
assert.equal(inspectAudioSpecification(oggOpus(), "ogg-16khz-16bit-mono-opus").codec, "opus");
|
|
126
|
+
assert.equal(inspectAudioSpecification(webmOpus(), "webm-24khz-16bit-mono-opus").container, "webm");
|
|
127
|
+
assert.throws(() =>
|
|
128
|
+
inspectAudioSpecification(Uint8Array.of(0x4f, 0x67, 0x67, 0x53).buffer, "ogg-16khz-16bit-mono-opus"),
|
|
129
|
+
);
|
|
130
|
+
assert.throws(() => inspectAudioSpecification(Uint8Array.of(0x1a, 0x45, 0xdf).buffer, "webm-24khz-16bit-mono-opus"));
|
|
131
|
+
});
|