@ssml-builder-js/azure-tts-client 2.15.0 → 2.17.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 +121 -7
- package/dist/index.d.ts +121 -7
- package/dist/index.js +786 -170
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +786 -171
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +17 -4
- package/src/errors.ts +66 -2
- package/src/index.ts +26 -2
- package/src/safe.ts +414 -118
- package/src/synthesis.ts +514 -69
- package/src/types.ts +89 -0
- package/src/voiceCatalog.ts +4 -0
- package/test/v216-pipeline.test.ts +110 -0
- package/test/v217-pipeline.test.ts +125 -0
package/src/safe.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createAzureUrlValidatorRunner,
|
|
3
|
+
validateAzureSsml,
|
|
4
|
+
type AzureUrlValidator,
|
|
5
|
+
type AzureValidationOptions,
|
|
6
|
+
type SsmlDiagnostic,
|
|
7
|
+
} from "@ssml-builder-js/ssml-core";
|
|
2
8
|
import {
|
|
3
9
|
type AzureTtsError,
|
|
4
10
|
type AzureTtsSynthesisError,
|
|
5
11
|
type SynthesisErrorKind,
|
|
6
12
|
toSynthesisError,
|
|
13
|
+
getRetryAfterDelayMs,
|
|
7
14
|
} from "./errors.ts";
|
|
8
15
|
import type { AzureTtsClient } from "./client.ts";
|
|
9
16
|
import { mergeSynthesisResults } from "./synthesis.ts";
|
|
@@ -12,6 +19,12 @@ import type {
|
|
|
12
19
|
SsmlSynthesisResult,
|
|
13
20
|
SynthesisProgressEvent,
|
|
14
21
|
SynthesizeChunksOptions,
|
|
22
|
+
RetryOptions,
|
|
23
|
+
SynthesisTimeouts,
|
|
24
|
+
SynthesizedChunk,
|
|
25
|
+
PartialChunkSynthesisResult,
|
|
26
|
+
CustomAudioMerger,
|
|
27
|
+
PostMergeValidator,
|
|
15
28
|
} from "./types.ts";
|
|
16
29
|
import type { AzureTtsOutputFormat } from "./outputFormats.ts";
|
|
17
30
|
|
|
@@ -36,6 +49,29 @@ export class ChunkValidationError extends Error {
|
|
|
36
49
|
}
|
|
37
50
|
}
|
|
38
51
|
|
|
52
|
+
export interface ChunkDiagnostics {
|
|
53
|
+
readonly chunkIndex: number;
|
|
54
|
+
readonly diagnostics: readonly SsmlDiagnostic[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class BatchChunkValidationError extends ChunkValidationError {
|
|
58
|
+
readonly chunkDiagnostics: readonly ChunkDiagnostics[];
|
|
59
|
+
readonly totalErrorCount: number;
|
|
60
|
+
readonly errorCount: number;
|
|
61
|
+
readonly totalErrors: number;
|
|
62
|
+
|
|
63
|
+
constructor(chunkDiagnostics: readonly ChunkDiagnostics[]) {
|
|
64
|
+
const first = chunkDiagnostics[0];
|
|
65
|
+
super(first?.chunkIndex ?? -1, first?.diagnostics ?? []);
|
|
66
|
+
this.name = "BatchChunkValidationError";
|
|
67
|
+
this.message = `SSML validation failed for ${chunkDiagnostics.length} chunk(s); the Azure Speech API was not called.`;
|
|
68
|
+
this.chunkDiagnostics = chunkDiagnostics;
|
|
69
|
+
this.totalErrorCount = chunkDiagnostics.reduce((total, chunk) => total + chunk.diagnostics.length, 0);
|
|
70
|
+
this.errorCount = this.totalErrorCount;
|
|
71
|
+
this.totalErrors = this.totalErrorCount;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
39
75
|
export type Result<T, E> =
|
|
40
76
|
| { readonly ok: true; readonly success: true; readonly status: "success"; readonly value: T }
|
|
41
77
|
| (E extends { readonly kind: infer Kind extends SynthesisErrorKind }
|
|
@@ -44,18 +80,29 @@ export type Result<T, E> =
|
|
|
44
80
|
readonly success: false;
|
|
45
81
|
readonly status: Kind;
|
|
46
82
|
readonly error: E;
|
|
83
|
+
readonly partialResult?: PartialChunkSynthesisResult;
|
|
47
84
|
}
|
|
48
85
|
: {
|
|
49
86
|
readonly ok: false;
|
|
50
87
|
readonly success: false;
|
|
51
88
|
readonly status: SynthesisErrorKind;
|
|
52
89
|
readonly error: E;
|
|
90
|
+
readonly partialResult?: PartialChunkSynthesisResult;
|
|
53
91
|
});
|
|
54
92
|
|
|
55
93
|
export type SynthesisResult<T, E> = Result<T, E>;
|
|
56
94
|
|
|
57
|
-
function failure<E extends { readonly kind: SynthesisErrorKind }>(
|
|
58
|
-
|
|
95
|
+
function failure<E extends { readonly kind: SynthesisErrorKind }>(
|
|
96
|
+
error: E,
|
|
97
|
+
partialResult?: PartialChunkSynthesisResult,
|
|
98
|
+
): Result<never, E> {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
success: false,
|
|
102
|
+
status: error.kind,
|
|
103
|
+
error,
|
|
104
|
+
...(partialResult ? { partialResult } : {}),
|
|
105
|
+
} as Result<never, E>;
|
|
59
106
|
}
|
|
60
107
|
|
|
61
108
|
export type Success<T> = Extract<Result<T, never>, { readonly ok: true }>;
|
|
@@ -74,6 +121,7 @@ export interface SynthesizeSsmlSafeOptions extends AzureValidationOptions {
|
|
|
74
121
|
/** Optional nested form for callers that want to keep validation settings grouped. */
|
|
75
122
|
validation?: AzureValidationOptions;
|
|
76
123
|
signal?: AbortSignal;
|
|
124
|
+
timeouts?: SynthesisTimeouts;
|
|
77
125
|
}
|
|
78
126
|
|
|
79
127
|
export interface SynthesizeSsmlChunksSafeOptions extends AzureValidationOptions {
|
|
@@ -81,13 +129,23 @@ export interface SynthesizeSsmlChunksSafeOptions extends AzureValidationOptions
|
|
|
81
129
|
outputFormat?: string;
|
|
82
130
|
signal?: AbortSignal;
|
|
83
131
|
timeoutMs?: number;
|
|
132
|
+
timeouts?: SynthesisTimeouts;
|
|
84
133
|
sourceNodePath?: string[];
|
|
85
134
|
onProgress?: (event: SynthesisProgressEvent) => void;
|
|
135
|
+
concurrency?: number;
|
|
136
|
+
retryOptions?: RetryOptions;
|
|
137
|
+
cancelOnFailure?: boolean;
|
|
138
|
+
resumeChunks?: readonly SynthesizedChunk[];
|
|
139
|
+
resumeChunkIndices?: readonly number[];
|
|
140
|
+
customMerger?: CustomAudioMerger;
|
|
141
|
+
outputMimeType?: string;
|
|
142
|
+
postMergeValidator?: PostMergeValidator;
|
|
86
143
|
}
|
|
87
144
|
|
|
88
145
|
export type SsmlSynthesisChunksSafeResult =
|
|
89
146
|
| Result<SsmlSynthesisResult, never>
|
|
90
147
|
| Result<never, ChunkValidationError>
|
|
148
|
+
| Result<never, BatchChunkValidationError>
|
|
91
149
|
| Result<never, SsmlSynthesisError | ChunkValidationError>;
|
|
92
150
|
|
|
93
151
|
interface SynthesisClient {
|
|
@@ -98,13 +156,142 @@ interface SynthesisClient {
|
|
|
98
156
|
): Promise<SsmlSynthesisResult>;
|
|
99
157
|
}
|
|
100
158
|
|
|
159
|
+
function partialResultFrom(error: unknown): PartialChunkSynthesisResult | undefined {
|
|
160
|
+
if (!error || typeof error !== "object") return undefined;
|
|
161
|
+
const partial = (error as { partialResult?: unknown }).partialResult;
|
|
162
|
+
if (!partial || typeof partial !== "object") return undefined;
|
|
163
|
+
return partial as PartialChunkSynthesisResult;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface SafeAbortScope {
|
|
167
|
+
signal: AbortSignal;
|
|
168
|
+
timedOut: () => boolean;
|
|
169
|
+
dispose: () => void;
|
|
170
|
+
abort: () => void;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function createSafeAbortScope(parent: AbortSignal | undefined, timeoutMs: number | undefined): SafeAbortScope {
|
|
174
|
+
const controller = new AbortController();
|
|
175
|
+
let didTimeout = false;
|
|
176
|
+
const onAbort = () => controller.abort();
|
|
177
|
+
if (parent?.aborted) controller.abort();
|
|
178
|
+
parent?.addEventListener("abort", onAbort, { once: true });
|
|
179
|
+
const timer =
|
|
180
|
+
timeoutMs !== undefined && timeoutMs > 0
|
|
181
|
+
? setTimeout(() => {
|
|
182
|
+
didTimeout = true;
|
|
183
|
+
controller.abort();
|
|
184
|
+
}, timeoutMs)
|
|
185
|
+
: undefined;
|
|
186
|
+
return {
|
|
187
|
+
signal: controller.signal,
|
|
188
|
+
timedOut: () => didTimeout,
|
|
189
|
+
dispose: () => {
|
|
190
|
+
if (timer) clearTimeout(timer);
|
|
191
|
+
parent?.removeEventListener("abort", onAbort);
|
|
192
|
+
},
|
|
193
|
+
abort: () => controller.abort(),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function isRetryable(error: unknown): boolean {
|
|
198
|
+
if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
|
|
199
|
+
const status =
|
|
200
|
+
error && typeof error === "object" && "status" in error ? (error as { status?: unknown }).status : undefined;
|
|
201
|
+
if (typeof status === "number" && status !== 0) return status === 429 || (status >= 500 && status < 600);
|
|
202
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
203
|
+
if (/\b4\d{2}\b/.test(message)) return false;
|
|
204
|
+
return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function delayForRetry(options: RetryOptions, attempt: number): number {
|
|
208
|
+
const maxDelay = Math.max(0, options.maxDelayMs);
|
|
209
|
+
const base = Math.min(maxDelay, Math.max(0, options.initialDelayMs) * 2 ** Math.max(0, attempt - 1));
|
|
210
|
+
return Math.floor(Math.random() * (base + 1));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function retryDelayForError(options: RetryOptions, attempt: number, error: unknown): number {
|
|
214
|
+
return getRetryAfterDelayMs(error) ?? delayForRetry(options, attempt);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function resolveConcurrency(value: number | undefined, total: number): number {
|
|
218
|
+
if (value === undefined) return 1;
|
|
219
|
+
if (value === Infinity) return Math.max(1, total);
|
|
220
|
+
return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function retryableSynthesis(
|
|
224
|
+
synthesize: () => Promise<SsmlSynthesisResult>,
|
|
225
|
+
options: RetryOptions | undefined,
|
|
226
|
+
signal: AbortSignal | undefined,
|
|
227
|
+
onRetry: (attempt: number, delayMs: number) => void,
|
|
228
|
+
): Promise<SsmlSynthesisResult> {
|
|
229
|
+
const retry = options
|
|
230
|
+
? {
|
|
231
|
+
maxRetries: Math.max(0, Math.floor(options.maxRetries)),
|
|
232
|
+
initialDelayMs: options.initialDelayMs,
|
|
233
|
+
maxDelayMs: options.maxDelayMs,
|
|
234
|
+
shouldRetry: options.shouldRetry,
|
|
235
|
+
}
|
|
236
|
+
: undefined;
|
|
237
|
+
let attempt = 0;
|
|
238
|
+
while (true) {
|
|
239
|
+
if (signal?.aborted) throw new Error("Speech synthesis was cancelled.");
|
|
240
|
+
try {
|
|
241
|
+
return await synthesize();
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (!retry || attempt >= retry.maxRetries || !(retry.shouldRetry?.(error, attempt + 1) ?? isRetryable(error)))
|
|
244
|
+
throw error;
|
|
245
|
+
attempt += 1;
|
|
246
|
+
const delayMs = retryDelayForError(retry, attempt, error);
|
|
247
|
+
onRetry(attempt, delayMs);
|
|
248
|
+
if (delayMs > 0)
|
|
249
|
+
await new Promise<void>((resolve, reject) => {
|
|
250
|
+
const timer = setTimeout(() => {
|
|
251
|
+
signal?.removeEventListener("abort", abort);
|
|
252
|
+
resolve();
|
|
253
|
+
}, delayMs);
|
|
254
|
+
const abort = () => {
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
signal?.removeEventListener("abort", abort);
|
|
257
|
+
reject(new Error("Speech synthesis was cancelled."));
|
|
258
|
+
};
|
|
259
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function sharedValidationOptions(
|
|
266
|
+
options: AzureValidationOptions & { timeouts?: SynthesisTimeouts },
|
|
267
|
+
signal?: AbortSignal,
|
|
268
|
+
): AzureValidationOptions {
|
|
269
|
+
const validator = options.urlValidator ?? options.customUrlValidator;
|
|
270
|
+
if (!validator) return signal ? withValidationSignal(options, signal) : options;
|
|
271
|
+
const runner = createAzureUrlValidatorRunner(validator as AzureUrlValidator, {
|
|
272
|
+
...(options.urlValidation ?? {}),
|
|
273
|
+
...(options.urlValidatorConcurrency !== undefined ? { concurrency: options.urlValidatorConcurrency } : {}),
|
|
274
|
+
...(options.timeouts?.urlValidationMs !== undefined
|
|
275
|
+
? { timeoutMs: options.timeouts.urlValidationMs }
|
|
276
|
+
: options.urlValidatorTimeoutMs !== undefined
|
|
277
|
+
? { timeoutMs: options.urlValidatorTimeoutMs }
|
|
278
|
+
: {}),
|
|
279
|
+
...(signal ? { signal } : {}),
|
|
280
|
+
...(options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}),
|
|
281
|
+
});
|
|
282
|
+
return {
|
|
283
|
+
...withValidationSignal(options, signal),
|
|
284
|
+
urlValidatorRunner: runner,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
101
288
|
/** Validates SSML before invoking Azure and converts validation/API failures to one result shape. */
|
|
102
289
|
export async function synthesizeSsmlSafe(
|
|
103
290
|
client: Pick<AzureTtsClient, "synthesizeSsml"> | SynthesisClient,
|
|
104
291
|
ssml: string,
|
|
105
292
|
options: SynthesizeSsmlSafeOptions = {},
|
|
106
293
|
): Promise<SsmlSynthesisSafeResult> {
|
|
107
|
-
const validationOptions =
|
|
294
|
+
const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
|
|
108
295
|
const diagnostics = await Promise.resolve(validateAzureSsml(ssml, validationOptions));
|
|
109
296
|
if (options.signal?.aborted) {
|
|
110
297
|
const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
|
|
@@ -124,7 +311,11 @@ export async function synthesizeSsmlSafe(
|
|
|
124
311
|
ok: true,
|
|
125
312
|
success: true,
|
|
126
313
|
status: "success",
|
|
127
|
-
value: await client.synthesizeSsml(ssml, {
|
|
314
|
+
value: await client.synthesizeSsml(ssml, {
|
|
315
|
+
signal: options.signal,
|
|
316
|
+
timeoutMs: options.timeouts?.perChunkMs,
|
|
317
|
+
timeouts: options.timeouts,
|
|
318
|
+
}),
|
|
128
319
|
};
|
|
129
320
|
} catch (error) {
|
|
130
321
|
const synthesisError = toSynthesisError(error);
|
|
@@ -138,7 +329,10 @@ export async function synthesizeSsmlChunksSafe(
|
|
|
138
329
|
chunks: readonly (SsmlSynthesisChunk | string)[],
|
|
139
330
|
options: SynthesizeSsmlChunksSafeOptions = {},
|
|
140
331
|
): Promise<SsmlSynthesisChunksSafeResult> {
|
|
141
|
-
const validationOptions =
|
|
332
|
+
const validationOptions = sharedValidationOptions(
|
|
333
|
+
{ ...(options.validation ?? options), timeouts: options.timeouts },
|
|
334
|
+
options.signal,
|
|
335
|
+
);
|
|
142
336
|
if (options.signal?.aborted) {
|
|
143
337
|
const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
|
|
144
338
|
return failure(error);
|
|
@@ -160,23 +354,34 @@ export async function synthesizeSsmlChunksSafe(
|
|
|
160
354
|
pending(index, "pending");
|
|
161
355
|
});
|
|
162
356
|
const validations = await Promise.all(
|
|
163
|
-
chunks.map(async (chunk) => {
|
|
357
|
+
chunks.map(async (chunk, index) => {
|
|
164
358
|
const ssml = typeof chunk === "string" ? chunk : chunk.ssml;
|
|
165
359
|
const sourceNodePath =
|
|
166
360
|
typeof chunk === "string" ? options.sourceNodePath : (chunk.sourceNodePath ?? options.sourceNodePath);
|
|
167
361
|
const diagnostics = await Promise.resolve(
|
|
168
|
-
validateAzureSsml(ssml, {
|
|
362
|
+
validateAzureSsml(ssml, {
|
|
363
|
+
...validationOptions,
|
|
364
|
+
...(sourceNodePath ? { sourceNodePath } : {}),
|
|
365
|
+
chunkIndex: index,
|
|
366
|
+
}),
|
|
169
367
|
);
|
|
170
368
|
return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
171
369
|
}),
|
|
172
370
|
);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
371
|
+
if (options.signal?.aborted) {
|
|
372
|
+
const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
|
|
373
|
+
return failure(error);
|
|
374
|
+
}
|
|
375
|
+
const chunkDiagnostics = validations
|
|
376
|
+
.map((diagnostics, chunkIndex) => ({ chunkIndex, diagnostics }))
|
|
377
|
+
.filter((entry) => entry.diagnostics.length > 0);
|
|
378
|
+
if (chunkDiagnostics.length > 0) {
|
|
379
|
+
const error = new BatchChunkValidationError(chunkDiagnostics);
|
|
380
|
+
for (const entry of chunkDiagnostics) pending(entry.chunkIndex, "failed", error);
|
|
177
381
|
return failure(error);
|
|
178
382
|
}
|
|
179
383
|
|
|
384
|
+
let fallbackJobScope: SafeAbortScope | undefined;
|
|
180
385
|
try {
|
|
181
386
|
if (client.synthesizeChunks) {
|
|
182
387
|
const normalizedChunks = chunks.map((chunk) => {
|
|
@@ -188,127 +393,218 @@ export async function synthesizeSsmlChunksSafe(
|
|
|
188
393
|
outputFormat: options.outputFormat,
|
|
189
394
|
signal: options.signal,
|
|
190
395
|
timeoutMs: options.timeoutMs,
|
|
396
|
+
timeouts: options.timeouts,
|
|
191
397
|
sourceNodePath: options.sourceNodePath,
|
|
398
|
+
concurrency: options.concurrency,
|
|
399
|
+
retryOptions: options.retryOptions,
|
|
400
|
+
cancelOnFailure: options.cancelOnFailure,
|
|
401
|
+
resumeChunks: options.resumeChunks,
|
|
402
|
+
resumeChunkIndices: options.resumeChunkIndices,
|
|
403
|
+
customMerger: options.customMerger,
|
|
404
|
+
outputMimeType: options.outputMimeType,
|
|
405
|
+
postMergeValidator: options.postMergeValidator,
|
|
192
406
|
});
|
|
193
407
|
return { ok: true, success: true, status: "success", value };
|
|
194
408
|
}
|
|
195
|
-
const results: SsmlSynthesisResult
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
409
|
+
const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
|
|
410
|
+
const cachedChunks = new Map((options.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
|
|
411
|
+
for (const [index, cached] of cachedChunks) {
|
|
412
|
+
if (index >= 0 && index < chunks.length) results[index] = cached;
|
|
413
|
+
}
|
|
414
|
+
const requestedIndices = options.resumeChunkIndices
|
|
415
|
+
? new Set(options.resumeChunkIndices.filter((index) => index >= 0 && index < chunks.length))
|
|
416
|
+
: undefined;
|
|
417
|
+
const shouldSynthesize = (index: number): boolean =>
|
|
418
|
+
!cachedChunks.has(index) && (requestedIndices === undefined || requestedIndices.has(index));
|
|
419
|
+
const jobScope =
|
|
420
|
+
chunks.length > 1 || options.timeouts?.totalJobMs !== undefined
|
|
421
|
+
? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs)
|
|
422
|
+
: undefined;
|
|
423
|
+
fallbackJobScope = jobScope;
|
|
424
|
+
const failedIndices = new Set<number>();
|
|
425
|
+
let firstError: unknown;
|
|
426
|
+
let completed = [...results].filter((result) => result !== undefined).length;
|
|
427
|
+
let nextIndex = 0;
|
|
428
|
+
const concurrency = resolveConcurrency(options.concurrency, chunks.length);
|
|
429
|
+
const worker = async (): Promise<void> => {
|
|
430
|
+
while (true) {
|
|
431
|
+
const index = nextIndex++;
|
|
432
|
+
if (index >= chunks.length) return;
|
|
433
|
+
if (!shouldSynthesize(index)) continue;
|
|
434
|
+
if (failedIndices.size > 0 && options.cancelOnFailure !== false) return;
|
|
435
|
+
const chunk = chunks[index];
|
|
436
|
+
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
437
|
+
const sourceNodePath = input.sourceNodePath;
|
|
438
|
+
const originalTextRange = input.originalTextRange;
|
|
439
|
+
pending(index, "synthesizing");
|
|
440
|
+
const startedAt = Date.now();
|
|
441
|
+
try {
|
|
442
|
+
const chunkTimeout = options.timeouts?.chunkWithRetriesMs ?? options.timeouts?.perChunkMs;
|
|
443
|
+
const chunkScope =
|
|
444
|
+
chunkTimeout !== undefined || jobScope
|
|
445
|
+
? createSafeAbortScope(jobScope?.signal ?? options.signal, chunkTimeout ?? options.timeoutMs)
|
|
446
|
+
: undefined;
|
|
447
|
+
const chunkSignal = chunkScope?.signal ?? options.signal;
|
|
448
|
+
let result: SsmlSynthesisResult;
|
|
449
|
+
try {
|
|
450
|
+
result = await retryableSynthesis(
|
|
451
|
+
() =>
|
|
452
|
+
client.synthesizeSsml(input.ssml, {
|
|
453
|
+
outputFormat: options.outputFormat,
|
|
454
|
+
signal: chunkSignal,
|
|
455
|
+
timeoutMs: options.timeouts?.perChunkMs ?? options.timeoutMs,
|
|
456
|
+
sourceNodePath: input.sourceNodePath ?? options.sourceNodePath,
|
|
457
|
+
}),
|
|
458
|
+
options.retryOptions,
|
|
459
|
+
chunkSignal,
|
|
460
|
+
(retryAttempt, nextRetryDelayMs) =>
|
|
461
|
+
options.onProgress?.({
|
|
462
|
+
currentChunk: completed,
|
|
463
|
+
totalChunks: chunks.length,
|
|
464
|
+
percent: chunks.length === 0 ? 100 : Math.round((completed / chunks.length) * 100),
|
|
465
|
+
chunkIndex: index,
|
|
466
|
+
originalTextRange: input.originalTextRange,
|
|
467
|
+
status: "synthesizing",
|
|
468
|
+
durationMs: Date.now() - startedAt,
|
|
469
|
+
retryAttempt,
|
|
470
|
+
nextRetryDelayMs,
|
|
471
|
+
isRetrying: true,
|
|
472
|
+
}),
|
|
473
|
+
);
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (chunkScope?.timedOut())
|
|
476
|
+
throw new Error(`Speech synthesis timed out after ${chunkTimeout ?? options.timeoutMs} ms.`);
|
|
477
|
+
throw error;
|
|
478
|
+
} finally {
|
|
479
|
+
chunkScope?.dispose();
|
|
480
|
+
}
|
|
481
|
+
results[index] = {
|
|
482
|
+
...result,
|
|
483
|
+
...(input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {}),
|
|
484
|
+
...(sourceNodePath
|
|
485
|
+
? {
|
|
486
|
+
boundaries: result.boundaries?.map((event) => ({
|
|
487
|
+
...event,
|
|
488
|
+
sourceNodePath: [...sourceNodePath],
|
|
489
|
+
...(event.originalTextRange
|
|
490
|
+
? { originalTextRange: { ...event.originalTextRange } }
|
|
491
|
+
: input.originalTextRange
|
|
492
|
+
? { originalTextRange: { ...input.originalTextRange } }
|
|
493
|
+
: {}),
|
|
494
|
+
})),
|
|
495
|
+
visemes: result.visemes?.map((event) => ({
|
|
496
|
+
...event,
|
|
497
|
+
sourceNodePath: [...sourceNodePath],
|
|
498
|
+
...(event.originalTextRange
|
|
499
|
+
? { originalTextRange: { ...event.originalTextRange } }
|
|
500
|
+
: input.originalTextRange
|
|
501
|
+
? { originalTextRange: { ...input.originalTextRange } }
|
|
502
|
+
: {}),
|
|
503
|
+
})),
|
|
504
|
+
bookmarks: result.bookmarks?.map((event) => ({
|
|
505
|
+
...event,
|
|
506
|
+
sourceNodePath: [...sourceNodePath],
|
|
507
|
+
...(event.originalTextRange
|
|
508
|
+
? { originalTextRange: { ...event.originalTextRange } }
|
|
509
|
+
: input.originalTextRange
|
|
510
|
+
? { originalTextRange: { ...input.originalTextRange } }
|
|
511
|
+
: {}),
|
|
512
|
+
})),
|
|
513
|
+
}
|
|
514
|
+
: {}),
|
|
515
|
+
...(originalTextRange
|
|
516
|
+
? {
|
|
517
|
+
boundaries: result.boundaries?.map((event) => ({
|
|
518
|
+
...event,
|
|
519
|
+
originalTextRange: event.originalTextRange
|
|
520
|
+
? { ...event.originalTextRange }
|
|
521
|
+
: { ...originalTextRange },
|
|
522
|
+
})),
|
|
523
|
+
wordBoundary: result.wordBoundary?.map((event) => ({
|
|
524
|
+
...event,
|
|
525
|
+
originalTextRange: event.originalTextRange
|
|
526
|
+
? { ...event.originalTextRange }
|
|
527
|
+
: { ...originalTextRange },
|
|
528
|
+
})),
|
|
529
|
+
wordBoundaries: result.wordBoundaries?.map((event) => ({
|
|
530
|
+
...event,
|
|
531
|
+
originalTextRange: event.originalTextRange
|
|
532
|
+
? { ...event.originalTextRange }
|
|
533
|
+
: { ...originalTextRange },
|
|
534
|
+
})),
|
|
535
|
+
visemes: result.visemes?.map((event) => ({
|
|
536
|
+
...event,
|
|
537
|
+
originalTextRange: event.originalTextRange
|
|
538
|
+
? { ...event.originalTextRange }
|
|
539
|
+
: { ...originalTextRange },
|
|
540
|
+
})),
|
|
541
|
+
bookmarks: result.bookmarks?.map((event) => ({
|
|
542
|
+
...event,
|
|
543
|
+
originalTextRange: event.originalTextRange
|
|
544
|
+
? { ...event.originalTextRange }
|
|
545
|
+
: { ...originalTextRange },
|
|
546
|
+
})),
|
|
547
|
+
}
|
|
548
|
+
: {}),
|
|
549
|
+
};
|
|
550
|
+
completed += 1;
|
|
551
|
+
options.onProgress?.({
|
|
552
|
+
currentChunk: completed,
|
|
553
|
+
totalChunks: chunks.length,
|
|
554
|
+
percent: chunks.length === 0 ? 100 : Math.round((completed / chunks.length) * 100),
|
|
555
|
+
chunkIndex: index,
|
|
556
|
+
originalTextRange: input.originalTextRange,
|
|
557
|
+
status: "success",
|
|
558
|
+
durationMs: Date.now() - startedAt,
|
|
559
|
+
});
|
|
560
|
+
} catch (error) {
|
|
561
|
+
failedIndices.add(index);
|
|
562
|
+
options.onProgress?.({
|
|
563
|
+
currentChunk: completed,
|
|
564
|
+
totalChunks: chunks.length,
|
|
565
|
+
percent: chunks.length === 0 ? 100 : Math.round((index / chunks.length) * 100),
|
|
566
|
+
chunkIndex: index,
|
|
567
|
+
originalTextRange: input.originalTextRange,
|
|
568
|
+
status: "failed",
|
|
569
|
+
durationMs: Date.now() - startedAt,
|
|
570
|
+
error,
|
|
571
|
+
});
|
|
572
|
+
if (options.cancelOnFailure !== false) jobScope?.abort();
|
|
573
|
+
firstError ??= error;
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
299
576
|
}
|
|
577
|
+
};
|
|
578
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
|
|
579
|
+
if (failedIndices.size > 0) {
|
|
580
|
+
const error = firstError ?? new Error("One or more SSML chunks failed to synthesize.");
|
|
581
|
+
(error as { partialResult?: PartialChunkSynthesisResult }).partialResult = {
|
|
582
|
+
synthesizedChunks: results.flatMap((result, chunkIndex) => (result ? [{ ...result, chunkIndex }] : [])),
|
|
583
|
+
completedChunks: results.flatMap((result, chunkIndex) => (result ? [{ ...result, chunkIndex }] : [])),
|
|
584
|
+
pendingChunkIndices: chunks.flatMap((_chunk, chunkIndex) => (results[chunkIndex] ? [] : [chunkIndex])),
|
|
585
|
+
failedChunkIndices: [...failedIndices],
|
|
586
|
+
totalChunks: chunks.length,
|
|
587
|
+
};
|
|
588
|
+
throw error;
|
|
300
589
|
}
|
|
590
|
+
const orderedResults = results.filter((result): result is SsmlSynthesisResult => result !== undefined);
|
|
301
591
|
return {
|
|
302
592
|
ok: true,
|
|
303
593
|
success: true,
|
|
304
594
|
status: "success",
|
|
305
|
-
value: mergeSynthesisResults(
|
|
595
|
+
value: await mergeSynthesisResults(orderedResults, {
|
|
306
596
|
format: (options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3") as AzureTtsOutputFormat,
|
|
597
|
+
signal: jobScope?.signal ?? options.signal,
|
|
598
|
+
customMerger: options.customMerger,
|
|
599
|
+
outputMimeType: options.outputMimeType,
|
|
600
|
+
postMergeValidator: options.postMergeValidator,
|
|
307
601
|
}),
|
|
308
602
|
};
|
|
309
603
|
} catch (error) {
|
|
310
604
|
const synthesisError = toSynthesisError(error);
|
|
311
|
-
return failure(synthesisError);
|
|
605
|
+
return failure(synthesisError, partialResultFrom(error));
|
|
606
|
+
} finally {
|
|
607
|
+
fallbackJobScope?.dispose();
|
|
312
608
|
}
|
|
313
609
|
}
|
|
314
610
|
|