@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/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
+ });