@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/src/safe.ts CHANGED
@@ -1,25 +1,63 @@
1
1
  import { validateAzureSsml, type AzureValidationOptions, type SsmlDiagnostic } from "@ssml-builder-js/ssml-core";
2
- import { AzureTtsError, createSpeechSdkError } from "./errors.ts";
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 type { SsmlSynthesisResult } from "./types.ts";
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
- readonly ok: false;
16
- readonly success: false;
17
- readonly status: "validation-error" | "azure-api-error";
18
- readonly error: E;
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, AzureTtsError>;
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: false,
56
- success: false,
57
- status: "validation-error",
58
- error: {
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
- return { ok: true, success: true, status: "success", value: await client.synthesizeSsml(ssml) };
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 azureError = error instanceof AzureTtsError ? error : createSpeechSdkError(error);
70
- return { ok: false, success: false, status: "azure-api-error", error: azureError };
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
+ }