@ssml-builder-js/azure-tts-client 2.13.0 → 2.15.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 +22 -0
- package/dist/index.d.mts +169 -17
- package/dist/index.d.ts +169 -17
- package/dist/index.js +619 -47
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +606 -45
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +39 -7
- package/src/errors.ts +74 -0
- package/src/index.ts +25 -3
- package/src/outputFormats.ts +14 -3
- package/src/safe.ts +273 -23
- package/src/synthesis.ts +435 -26
- package/src/types.ts +48 -2
- package/src/voiceCatalog.ts +15 -0
- package/test/synthesis.test.ts +64 -0
- package/test/v213-pipeline.test.ts +27 -16
- package/test/v214-pipeline.test.ts +114 -0
- package/test/v215-pipeline.test.ts +104 -0
package/src/safe.ts
CHANGED
|
@@ -1,25 +1,63 @@
|
|
|
1
1
|
import { validateAzureSsml, type AzureValidationOptions, type SsmlDiagnostic } from "@ssml-builder-js/ssml-core";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type AzureTtsError,
|
|
4
|
+
type AzureTtsSynthesisError,
|
|
5
|
+
type SynthesisErrorKind,
|
|
6
|
+
toSynthesisError,
|
|
7
|
+
} from "./errors.ts";
|
|
3
8
|
import type { AzureTtsClient } from "./client.ts";
|
|
4
|
-
import
|
|
9
|
+
import { mergeSynthesisResults } from "./synthesis.ts";
|
|
10
|
+
import type {
|
|
11
|
+
SsmlSynthesisChunk,
|
|
12
|
+
SsmlSynthesisResult,
|
|
13
|
+
SynthesisProgressEvent,
|
|
14
|
+
SynthesizeChunksOptions,
|
|
15
|
+
} from "./types.ts";
|
|
16
|
+
import type { AzureTtsOutputFormat } from "./outputFormats.ts";
|
|
5
17
|
|
|
6
18
|
export interface SsmlValidationError {
|
|
7
|
-
readonly kind: "validation";
|
|
19
|
+
readonly kind: "validation-error";
|
|
8
20
|
readonly message: string;
|
|
9
21
|
readonly diagnostics: readonly SsmlDiagnostic[];
|
|
10
22
|
}
|
|
11
23
|
|
|
24
|
+
export type SsmlSynthesisError = SsmlValidationError | AzureTtsSynthesisError;
|
|
25
|
+
|
|
26
|
+
export class ChunkValidationError extends Error {
|
|
27
|
+
readonly kind = "validation-error" as const;
|
|
28
|
+
readonly chunkIndex: number;
|
|
29
|
+
readonly diagnostics: readonly SsmlDiagnostic[];
|
|
30
|
+
|
|
31
|
+
constructor(chunkIndex: number, diagnostics: readonly SsmlDiagnostic[]) {
|
|
32
|
+
super(`SSML validation failed for chunk ${chunkIndex}; the Azure Speech API was not called.`);
|
|
33
|
+
this.name = "ChunkValidationError";
|
|
34
|
+
this.chunkIndex = chunkIndex;
|
|
35
|
+
this.diagnostics = diagnostics;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
12
39
|
export type Result<T, E> =
|
|
13
40
|
| { readonly ok: true; readonly success: true; readonly status: "success"; readonly value: T }
|
|
14
|
-
| {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
41
|
+
| (E extends { readonly kind: infer Kind extends SynthesisErrorKind }
|
|
42
|
+
? {
|
|
43
|
+
readonly ok: false;
|
|
44
|
+
readonly success: false;
|
|
45
|
+
readonly status: Kind;
|
|
46
|
+
readonly error: E;
|
|
47
|
+
}
|
|
48
|
+
: {
|
|
49
|
+
readonly ok: false;
|
|
50
|
+
readonly success: false;
|
|
51
|
+
readonly status: SynthesisErrorKind;
|
|
52
|
+
readonly error: E;
|
|
53
|
+
});
|
|
20
54
|
|
|
21
55
|
export type SynthesisResult<T, E> = Result<T, E>;
|
|
22
56
|
|
|
57
|
+
function failure<E extends { readonly kind: SynthesisErrorKind }>(error: E): Result<never, E> {
|
|
58
|
+
return { ok: false, success: false, status: error.kind, error } as Result<never, E>;
|
|
59
|
+
}
|
|
60
|
+
|
|
23
61
|
export type Success<T> = Extract<Result<T, never>, { readonly ok: true }>;
|
|
24
62
|
export type ValidationErrorResult = Extract<
|
|
25
63
|
Result<never, SsmlValidationError>,
|
|
@@ -30,15 +68,34 @@ export type AzureApiErrorResult = Extract<Result<never, AzureTtsError>, { readon
|
|
|
30
68
|
export type SsmlSynthesisSafeResult =
|
|
31
69
|
| Result<SsmlSynthesisResult, never>
|
|
32
70
|
| Result<never, SsmlValidationError>
|
|
33
|
-
| Result<never,
|
|
71
|
+
| Result<never, SsmlSynthesisError>;
|
|
34
72
|
|
|
35
73
|
export interface SynthesizeSsmlSafeOptions extends AzureValidationOptions {
|
|
36
74
|
/** Optional nested form for callers that want to keep validation settings grouped. */
|
|
37
75
|
validation?: AzureValidationOptions;
|
|
76
|
+
signal?: AbortSignal;
|
|
38
77
|
}
|
|
39
78
|
|
|
79
|
+
export interface SynthesizeSsmlChunksSafeOptions extends AzureValidationOptions {
|
|
80
|
+
validation?: AzureValidationOptions;
|
|
81
|
+
outputFormat?: string;
|
|
82
|
+
signal?: AbortSignal;
|
|
83
|
+
timeoutMs?: number;
|
|
84
|
+
sourceNodePath?: string[];
|
|
85
|
+
onProgress?: (event: SynthesisProgressEvent) => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type SsmlSynthesisChunksSafeResult =
|
|
89
|
+
| Result<SsmlSynthesisResult, never>
|
|
90
|
+
| Result<never, ChunkValidationError>
|
|
91
|
+
| Result<never, SsmlSynthesisError | ChunkValidationError>;
|
|
92
|
+
|
|
40
93
|
interface SynthesisClient {
|
|
41
|
-
synthesizeSsml(ssml: string): Promise<SsmlSynthesisResult>;
|
|
94
|
+
synthesizeSsml(ssml: string, options?: Partial<SynthesizeChunksOptions>): Promise<SsmlSynthesisResult>;
|
|
95
|
+
synthesizeChunks?(
|
|
96
|
+
chunks: readonly (SsmlSynthesisChunk | string)[],
|
|
97
|
+
options?: SynthesizeChunksOptions,
|
|
98
|
+
): Promise<SsmlSynthesisResult>;
|
|
42
99
|
}
|
|
43
100
|
|
|
44
101
|
/** Validates SSML before invoking Azure and converts validation/API failures to one result shape. */
|
|
@@ -47,26 +104,219 @@ export async function synthesizeSsmlSafe(
|
|
|
47
104
|
ssml: string,
|
|
48
105
|
options: SynthesizeSsmlSafeOptions = {},
|
|
49
106
|
): Promise<SsmlSynthesisSafeResult> {
|
|
50
|
-
const validationOptions = options.validation ?? options;
|
|
107
|
+
const validationOptions = withValidationSignal(options.validation ?? options, options.signal);
|
|
51
108
|
const diagnostics = await Promise.resolve(validateAzureSsml(ssml, validationOptions));
|
|
109
|
+
if (options.signal?.aborted) {
|
|
110
|
+
const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
|
|
111
|
+
return failure(error);
|
|
112
|
+
}
|
|
52
113
|
const errors = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
53
114
|
if (errors.length > 0) {
|
|
115
|
+
return failure({
|
|
116
|
+
kind: "validation-error",
|
|
117
|
+
message: "SSML validation failed; the Azure Speech API was not called.",
|
|
118
|
+
diagnostics: errors,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
54
123
|
return {
|
|
55
|
-
ok:
|
|
56
|
-
success:
|
|
57
|
-
status: "
|
|
58
|
-
|
|
59
|
-
kind: "validation",
|
|
60
|
-
message: "SSML validation failed; the Azure Speech API was not called.",
|
|
61
|
-
diagnostics: errors,
|
|
62
|
-
},
|
|
124
|
+
ok: true,
|
|
125
|
+
success: true,
|
|
126
|
+
status: "success",
|
|
127
|
+
value: await client.synthesizeSsml(ssml, { signal: options.signal }),
|
|
63
128
|
};
|
|
129
|
+
} catch (error) {
|
|
130
|
+
const synthesisError = toSynthesisError(error);
|
|
131
|
+
return failure(synthesisError);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Validates every chunk before synthesis and returns a chunk-addressable result. */
|
|
136
|
+
export async function synthesizeSsmlChunksSafe(
|
|
137
|
+
client: Pick<AzureTtsClient, "synthesizeSsml" | "synthesizeChunks"> | SynthesisClient,
|
|
138
|
+
chunks: readonly (SsmlSynthesisChunk | string)[],
|
|
139
|
+
options: SynthesizeSsmlChunksSafeOptions = {},
|
|
140
|
+
): Promise<SsmlSynthesisChunksSafeResult> {
|
|
141
|
+
const validationOptions = withValidationSignal(options.validation ?? options, options.signal);
|
|
142
|
+
if (options.signal?.aborted) {
|
|
143
|
+
const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
|
|
144
|
+
return failure(error);
|
|
145
|
+
}
|
|
146
|
+
const pending = (index: number, status: SynthesisProgressEvent["status"], error?: unknown): void => {
|
|
147
|
+
options.onProgress?.({
|
|
148
|
+
currentChunk: status === "success" ? index + 1 : index,
|
|
149
|
+
totalChunks: chunks.length,
|
|
150
|
+
percent:
|
|
151
|
+
chunks.length === 0 ? 100 : Math.round(((status === "success" ? index + 1 : index) / chunks.length) * 100),
|
|
152
|
+
chunkIndex: index,
|
|
153
|
+
originalTextRange: typeof chunks[index] === "string" ? undefined : chunks[index]?.originalTextRange,
|
|
154
|
+
status,
|
|
155
|
+
durationMs: 0,
|
|
156
|
+
...(error ? { error } : {}),
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
chunks.forEach((_chunk, index) => {
|
|
160
|
+
pending(index, "pending");
|
|
161
|
+
});
|
|
162
|
+
const validations = await Promise.all(
|
|
163
|
+
chunks.map(async (chunk) => {
|
|
164
|
+
const ssml = typeof chunk === "string" ? chunk : chunk.ssml;
|
|
165
|
+
const sourceNodePath =
|
|
166
|
+
typeof chunk === "string" ? options.sourceNodePath : (chunk.sourceNodePath ?? options.sourceNodePath);
|
|
167
|
+
const diagnostics = await Promise.resolve(
|
|
168
|
+
validateAzureSsml(ssml, { ...validationOptions, ...(sourceNodePath ? { sourceNodePath } : {}) }),
|
|
169
|
+
);
|
|
170
|
+
return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
|
|
174
|
+
if (firstInvalidIndex >= 0) {
|
|
175
|
+
const error = new ChunkValidationError(firstInvalidIndex, validations[firstInvalidIndex] ?? []);
|
|
176
|
+
pending(firstInvalidIndex, "failed", error);
|
|
177
|
+
return failure(error);
|
|
64
178
|
}
|
|
65
179
|
|
|
66
180
|
try {
|
|
67
|
-
|
|
181
|
+
if (client.synthesizeChunks) {
|
|
182
|
+
const normalizedChunks = chunks.map((chunk) => {
|
|
183
|
+
if (typeof chunk === "string" || chunk.sourceNodePath || !options.sourceNodePath) return chunk;
|
|
184
|
+
return { ...chunk, sourceNodePath: [...options.sourceNodePath] };
|
|
185
|
+
});
|
|
186
|
+
const value = await client.synthesizeChunks(normalizedChunks, {
|
|
187
|
+
onProgress: options.onProgress,
|
|
188
|
+
outputFormat: options.outputFormat,
|
|
189
|
+
signal: options.signal,
|
|
190
|
+
timeoutMs: options.timeoutMs,
|
|
191
|
+
sourceNodePath: options.sourceNodePath,
|
|
192
|
+
});
|
|
193
|
+
return { ok: true, success: true, status: "success", value };
|
|
194
|
+
}
|
|
195
|
+
const results: SsmlSynthesisResult[] = [];
|
|
196
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
197
|
+
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
198
|
+
const sourceNodePath = input.sourceNodePath;
|
|
199
|
+
const originalTextRange = input.originalTextRange;
|
|
200
|
+
pending(index, "synthesizing");
|
|
201
|
+
const startedAt = Date.now();
|
|
202
|
+
try {
|
|
203
|
+
const result = await client.synthesizeSsml(input.ssml, {
|
|
204
|
+
outputFormat: options.outputFormat,
|
|
205
|
+
signal: options.signal,
|
|
206
|
+
timeoutMs: options.timeoutMs,
|
|
207
|
+
sourceNodePath: input.sourceNodePath ?? options.sourceNodePath,
|
|
208
|
+
});
|
|
209
|
+
results.push({
|
|
210
|
+
...result,
|
|
211
|
+
...(input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {}),
|
|
212
|
+
...(sourceNodePath
|
|
213
|
+
? {
|
|
214
|
+
boundaries: result.boundaries?.map((event) => ({
|
|
215
|
+
...event,
|
|
216
|
+
sourceNodePath: [...sourceNodePath],
|
|
217
|
+
...(event.originalTextRange
|
|
218
|
+
? { originalTextRange: { ...event.originalTextRange } }
|
|
219
|
+
: input.originalTextRange
|
|
220
|
+
? { originalTextRange: { ...input.originalTextRange } }
|
|
221
|
+
: {}),
|
|
222
|
+
})),
|
|
223
|
+
visemes: result.visemes?.map((event) => ({
|
|
224
|
+
...event,
|
|
225
|
+
sourceNodePath: [...sourceNodePath],
|
|
226
|
+
...(event.originalTextRange
|
|
227
|
+
? { originalTextRange: { ...event.originalTextRange } }
|
|
228
|
+
: input.originalTextRange
|
|
229
|
+
? { originalTextRange: { ...input.originalTextRange } }
|
|
230
|
+
: {}),
|
|
231
|
+
})),
|
|
232
|
+
bookmarks: result.bookmarks?.map((event) => ({
|
|
233
|
+
...event,
|
|
234
|
+
sourceNodePath: [...sourceNodePath],
|
|
235
|
+
...(event.originalTextRange
|
|
236
|
+
? { originalTextRange: { ...event.originalTextRange } }
|
|
237
|
+
: input.originalTextRange
|
|
238
|
+
? { originalTextRange: { ...input.originalTextRange } }
|
|
239
|
+
: {}),
|
|
240
|
+
})),
|
|
241
|
+
}
|
|
242
|
+
: {}),
|
|
243
|
+
...(originalTextRange
|
|
244
|
+
? {
|
|
245
|
+
boundaries: result.boundaries?.map((event) => ({
|
|
246
|
+
...event,
|
|
247
|
+
originalTextRange: event.originalTextRange
|
|
248
|
+
? { ...event.originalTextRange }
|
|
249
|
+
: { ...originalTextRange },
|
|
250
|
+
})),
|
|
251
|
+
wordBoundary: result.wordBoundary?.map((event) => ({
|
|
252
|
+
...event,
|
|
253
|
+
originalTextRange: event.originalTextRange
|
|
254
|
+
? { ...event.originalTextRange }
|
|
255
|
+
: { ...originalTextRange },
|
|
256
|
+
})),
|
|
257
|
+
wordBoundaries: result.wordBoundaries?.map((event) => ({
|
|
258
|
+
...event,
|
|
259
|
+
originalTextRange: event.originalTextRange
|
|
260
|
+
? { ...event.originalTextRange }
|
|
261
|
+
: { ...originalTextRange },
|
|
262
|
+
})),
|
|
263
|
+
visemes: result.visemes?.map((event) => ({
|
|
264
|
+
...event,
|
|
265
|
+
originalTextRange: event.originalTextRange
|
|
266
|
+
? { ...event.originalTextRange }
|
|
267
|
+
: { ...originalTextRange },
|
|
268
|
+
})),
|
|
269
|
+
bookmarks: result.bookmarks?.map((event) => ({
|
|
270
|
+
...event,
|
|
271
|
+
originalTextRange: event.originalTextRange
|
|
272
|
+
? { ...event.originalTextRange }
|
|
273
|
+
: { ...originalTextRange },
|
|
274
|
+
})),
|
|
275
|
+
}
|
|
276
|
+
: {}),
|
|
277
|
+
});
|
|
278
|
+
options.onProgress?.({
|
|
279
|
+
currentChunk: index + 1,
|
|
280
|
+
totalChunks: chunks.length,
|
|
281
|
+
percent: chunks.length === 0 ? 100 : Math.round(((index + 1) / chunks.length) * 100),
|
|
282
|
+
chunkIndex: index,
|
|
283
|
+
originalTextRange: input.originalTextRange,
|
|
284
|
+
status: "success",
|
|
285
|
+
durationMs: Date.now() - startedAt,
|
|
286
|
+
});
|
|
287
|
+
} catch (error) {
|
|
288
|
+
options.onProgress?.({
|
|
289
|
+
currentChunk: index,
|
|
290
|
+
totalChunks: chunks.length,
|
|
291
|
+
percent: chunks.length === 0 ? 100 : Math.round((index / chunks.length) * 100),
|
|
292
|
+
chunkIndex: index,
|
|
293
|
+
originalTextRange: input.originalTextRange,
|
|
294
|
+
status: "failed",
|
|
295
|
+
durationMs: Date.now() - startedAt,
|
|
296
|
+
error,
|
|
297
|
+
});
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
ok: true,
|
|
303
|
+
success: true,
|
|
304
|
+
status: "success",
|
|
305
|
+
value: mergeSynthesisResults(results, {
|
|
306
|
+
format: (options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3") as AzureTtsOutputFormat,
|
|
307
|
+
}),
|
|
308
|
+
};
|
|
68
309
|
} catch (error) {
|
|
69
|
-
const
|
|
70
|
-
return
|
|
310
|
+
const synthesisError = toSynthesisError(error);
|
|
311
|
+
return failure(synthesisError);
|
|
71
312
|
}
|
|
72
313
|
}
|
|
314
|
+
|
|
315
|
+
function withValidationSignal(options: AzureValidationOptions, signal?: AbortSignal): AzureValidationOptions {
|
|
316
|
+
if (!signal) return options;
|
|
317
|
+
return {
|
|
318
|
+
...options,
|
|
319
|
+
urlValidatorSignal: signal,
|
|
320
|
+
urlValidation: { ...(options.urlValidation ?? {}), signal },
|
|
321
|
+
};
|
|
322
|
+
}
|