@ssml-builder-js/azure-tts-client 2.16.0 → 2.18.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 +21 -0
- package/dist/index.d.mts +223 -127
- package/dist/index.d.ts +223 -127
- package/dist/index.js +552 -98
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +549 -98
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +29 -4
- package/src/errors.ts +44 -2
- package/src/index.ts +19 -2
- package/src/outputFormats.ts +3 -0
- package/src/safe.ts +280 -42
- package/src/synthesis.ts +380 -57
- package/src/types.ts +85 -1
- package/src/voiceCatalog.ts +4 -0
- package/test/v217-pipeline.test.ts +125 -0
- package/test/v218-pipeline.test.ts +91 -0
package/src/safe.ts
CHANGED
|
@@ -10,15 +10,23 @@ import {
|
|
|
10
10
|
type AzureTtsSynthesisError,
|
|
11
11
|
type SynthesisErrorKind,
|
|
12
12
|
toSynthesisError,
|
|
13
|
+
getRetryAfterDelayMs,
|
|
13
14
|
} from "./errors.ts";
|
|
14
15
|
import type { AzureTtsClient } from "./client.ts";
|
|
15
|
-
import { mergeSynthesisResults } from "./synthesis.ts";
|
|
16
|
+
import { computeChunkFingerprint, mergeSynthesisResults } from "./synthesis.ts";
|
|
16
17
|
import type {
|
|
17
18
|
SsmlSynthesisChunk,
|
|
18
19
|
SsmlSynthesisResult,
|
|
19
20
|
SynthesisProgressEvent,
|
|
20
21
|
SynthesizeChunksOptions,
|
|
21
22
|
RetryOptions,
|
|
23
|
+
SynthesisTimeouts,
|
|
24
|
+
SynthesizedChunk,
|
|
25
|
+
PartialChunkSynthesisResult,
|
|
26
|
+
CustomAudioMerger,
|
|
27
|
+
PostMergeValidator,
|
|
28
|
+
ChunkExecutionState,
|
|
29
|
+
ResumeValidationMode,
|
|
22
30
|
} from "./types.ts";
|
|
23
31
|
import type { AzureTtsOutputFormat } from "./outputFormats.ts";
|
|
24
32
|
|
|
@@ -43,6 +51,29 @@ export class ChunkValidationError extends Error {
|
|
|
43
51
|
}
|
|
44
52
|
}
|
|
45
53
|
|
|
54
|
+
export interface ChunkDiagnostics {
|
|
55
|
+
readonly chunkIndex: number;
|
|
56
|
+
readonly diagnostics: readonly SsmlDiagnostic[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class BatchChunkValidationError extends ChunkValidationError {
|
|
60
|
+
readonly chunkDiagnostics: readonly ChunkDiagnostics[];
|
|
61
|
+
readonly totalErrorCount: number;
|
|
62
|
+
readonly errorCount: number;
|
|
63
|
+
readonly totalErrors: number;
|
|
64
|
+
|
|
65
|
+
constructor(chunkDiagnostics: readonly ChunkDiagnostics[]) {
|
|
66
|
+
const first = chunkDiagnostics[0];
|
|
67
|
+
super(first?.chunkIndex ?? -1, first?.diagnostics ?? []);
|
|
68
|
+
this.name = "BatchChunkValidationError";
|
|
69
|
+
this.message = `SSML validation failed for ${chunkDiagnostics.length} chunk(s); the Azure Speech API was not called.`;
|
|
70
|
+
this.chunkDiagnostics = chunkDiagnostics;
|
|
71
|
+
this.totalErrorCount = chunkDiagnostics.reduce((total, chunk) => total + chunk.diagnostics.length, 0);
|
|
72
|
+
this.errorCount = this.totalErrorCount;
|
|
73
|
+
this.totalErrors = this.totalErrorCount;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
46
77
|
export type Result<T, E> =
|
|
47
78
|
| { readonly ok: true; readonly success: true; readonly status: "success"; readonly value: T }
|
|
48
79
|
| (E extends { readonly kind: infer Kind extends SynthesisErrorKind }
|
|
@@ -51,18 +82,29 @@ export type Result<T, E> =
|
|
|
51
82
|
readonly success: false;
|
|
52
83
|
readonly status: Kind;
|
|
53
84
|
readonly error: E;
|
|
85
|
+
readonly partialResult?: PartialChunkSynthesisResult;
|
|
54
86
|
}
|
|
55
87
|
: {
|
|
56
88
|
readonly ok: false;
|
|
57
89
|
readonly success: false;
|
|
58
90
|
readonly status: SynthesisErrorKind;
|
|
59
91
|
readonly error: E;
|
|
92
|
+
readonly partialResult?: PartialChunkSynthesisResult;
|
|
60
93
|
});
|
|
61
94
|
|
|
62
95
|
export type SynthesisResult<T, E> = Result<T, E>;
|
|
63
96
|
|
|
64
|
-
function failure<E extends { readonly kind: SynthesisErrorKind }>(
|
|
65
|
-
|
|
97
|
+
function failure<E extends { readonly kind: SynthesisErrorKind }>(
|
|
98
|
+
error: E,
|
|
99
|
+
partialResult?: PartialChunkSynthesisResult,
|
|
100
|
+
): Result<never, E> {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
success: false,
|
|
104
|
+
status: error.kind,
|
|
105
|
+
error,
|
|
106
|
+
...(partialResult ? { partialResult } : {}),
|
|
107
|
+
} as Result<never, E>;
|
|
66
108
|
}
|
|
67
109
|
|
|
68
110
|
export type Success<T> = Extract<Result<T, never>, { readonly ok: true }>;
|
|
@@ -81,6 +123,7 @@ export interface SynthesizeSsmlSafeOptions extends AzureValidationOptions {
|
|
|
81
123
|
/** Optional nested form for callers that want to keep validation settings grouped. */
|
|
82
124
|
validation?: AzureValidationOptions;
|
|
83
125
|
signal?: AbortSignal;
|
|
126
|
+
timeouts?: SynthesisTimeouts;
|
|
84
127
|
}
|
|
85
128
|
|
|
86
129
|
export interface SynthesizeSsmlChunksSafeOptions extends AzureValidationOptions {
|
|
@@ -88,15 +131,24 @@ export interface SynthesizeSsmlChunksSafeOptions extends AzureValidationOptions
|
|
|
88
131
|
outputFormat?: string;
|
|
89
132
|
signal?: AbortSignal;
|
|
90
133
|
timeoutMs?: number;
|
|
134
|
+
timeouts?: SynthesisTimeouts;
|
|
91
135
|
sourceNodePath?: string[];
|
|
92
136
|
onProgress?: (event: SynthesisProgressEvent) => void;
|
|
93
137
|
concurrency?: number;
|
|
94
138
|
retryOptions?: RetryOptions;
|
|
139
|
+
cancelOnFailure?: boolean;
|
|
140
|
+
resumeChunks?: readonly SynthesizedChunk[];
|
|
141
|
+
resumeChunkIndices?: readonly number[];
|
|
142
|
+
customMerger?: CustomAudioMerger;
|
|
143
|
+
outputMimeType?: string;
|
|
144
|
+
postMergeValidator?: PostMergeValidator;
|
|
145
|
+
resumeValidation?: ResumeValidationMode;
|
|
95
146
|
}
|
|
96
147
|
|
|
97
148
|
export type SsmlSynthesisChunksSafeResult =
|
|
98
149
|
| Result<SsmlSynthesisResult, never>
|
|
99
150
|
| Result<never, ChunkValidationError>
|
|
151
|
+
| Result<never, BatchChunkValidationError>
|
|
100
152
|
| Result<never, SsmlSynthesisError | ChunkValidationError>;
|
|
101
153
|
|
|
102
154
|
interface SynthesisClient {
|
|
@@ -107,6 +159,44 @@ interface SynthesisClient {
|
|
|
107
159
|
): Promise<SsmlSynthesisResult>;
|
|
108
160
|
}
|
|
109
161
|
|
|
162
|
+
function partialResultFrom(error: unknown): PartialChunkSynthesisResult | undefined {
|
|
163
|
+
if (!error || typeof error !== "object") return undefined;
|
|
164
|
+
const partial = (error as { partialResult?: unknown }).partialResult;
|
|
165
|
+
if (!partial || typeof partial !== "object") return undefined;
|
|
166
|
+
return partial as PartialChunkSynthesisResult;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface SafeAbortScope {
|
|
170
|
+
signal: AbortSignal;
|
|
171
|
+
timedOut: () => boolean;
|
|
172
|
+
dispose: () => void;
|
|
173
|
+
abort: () => void;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function createSafeAbortScope(parent: AbortSignal | undefined, timeoutMs: number | undefined): SafeAbortScope {
|
|
177
|
+
const controller = new AbortController();
|
|
178
|
+
let didTimeout = false;
|
|
179
|
+
const onAbort = () => controller.abort();
|
|
180
|
+
if (parent?.aborted) controller.abort();
|
|
181
|
+
parent?.addEventListener("abort", onAbort, { once: true });
|
|
182
|
+
const timer =
|
|
183
|
+
timeoutMs !== undefined && timeoutMs > 0
|
|
184
|
+
? setTimeout(() => {
|
|
185
|
+
didTimeout = true;
|
|
186
|
+
controller.abort();
|
|
187
|
+
}, timeoutMs)
|
|
188
|
+
: undefined;
|
|
189
|
+
return {
|
|
190
|
+
signal: controller.signal,
|
|
191
|
+
timedOut: () => didTimeout,
|
|
192
|
+
dispose: () => {
|
|
193
|
+
if (timer) clearTimeout(timer);
|
|
194
|
+
parent?.removeEventListener("abort", onAbort);
|
|
195
|
+
},
|
|
196
|
+
abort: () => controller.abort(),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
110
200
|
function isRetryable(error: unknown): boolean {
|
|
111
201
|
if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
|
|
112
202
|
const status =
|
|
@@ -123,6 +213,10 @@ function delayForRetry(options: RetryOptions, attempt: number): number {
|
|
|
123
213
|
return Math.floor(Math.random() * (base + 1));
|
|
124
214
|
}
|
|
125
215
|
|
|
216
|
+
function retryDelayForError(options: RetryOptions, attempt: number, error: unknown): number {
|
|
217
|
+
return getRetryAfterDelayMs(error) ?? delayForRetry(options, attempt);
|
|
218
|
+
}
|
|
219
|
+
|
|
126
220
|
function resolveConcurrency(value: number | undefined, total: number): number {
|
|
127
221
|
if (value === undefined) return 1;
|
|
128
222
|
if (value === Infinity) return Math.max(1, total);
|
|
@@ -134,12 +228,14 @@ async function retryableSynthesis(
|
|
|
134
228
|
options: RetryOptions | undefined,
|
|
135
229
|
signal: AbortSignal | undefined,
|
|
136
230
|
onRetry: (attempt: number, delayMs: number) => void,
|
|
231
|
+
deadlineAtMs?: number,
|
|
137
232
|
): Promise<SsmlSynthesisResult> {
|
|
138
233
|
const retry = options
|
|
139
234
|
? {
|
|
140
235
|
maxRetries: Math.max(0, Math.floor(options.maxRetries)),
|
|
141
236
|
initialDelayMs: options.initialDelayMs,
|
|
142
237
|
maxDelayMs: options.maxDelayMs,
|
|
238
|
+
shouldRetry: options.shouldRetry,
|
|
143
239
|
}
|
|
144
240
|
: undefined;
|
|
145
241
|
let attempt = 0;
|
|
@@ -148,9 +244,18 @@ async function retryableSynthesis(
|
|
|
148
244
|
try {
|
|
149
245
|
return await synthesize();
|
|
150
246
|
} catch (error) {
|
|
151
|
-
if (!retry || attempt >= retry.maxRetries || !
|
|
247
|
+
if (!retry || attempt >= retry.maxRetries || !(retry.shouldRetry?.(error, attempt + 1) ?? isRetryable(error)))
|
|
248
|
+
throw error;
|
|
152
249
|
attempt += 1;
|
|
153
|
-
const delayMs =
|
|
250
|
+
const delayMs = retryDelayForError(retry, attempt, error);
|
|
251
|
+
const retryAfterMs = getRetryAfterDelayMs(error);
|
|
252
|
+
const remainingMs = deadlineAtMs === undefined ? undefined : Math.max(0, deadlineAtMs - Date.now());
|
|
253
|
+
if (
|
|
254
|
+
retryAfterMs !== undefined &&
|
|
255
|
+
(retryAfterMs > retry.maxDelayMs || (remainingMs !== undefined && retryAfterMs > remainingMs))
|
|
256
|
+
) {
|
|
257
|
+
throw new Error("Speech synthesis timed out because Retry-After exceeded the available retry budget.");
|
|
258
|
+
}
|
|
154
259
|
onRetry(attempt, delayMs);
|
|
155
260
|
if (delayMs > 0)
|
|
156
261
|
await new Promise<void>((resolve, reject) => {
|
|
@@ -169,13 +274,20 @@ async function retryableSynthesis(
|
|
|
169
274
|
}
|
|
170
275
|
}
|
|
171
276
|
|
|
172
|
-
function sharedValidationOptions(
|
|
277
|
+
function sharedValidationOptions(
|
|
278
|
+
options: AzureValidationOptions & { timeouts?: SynthesisTimeouts },
|
|
279
|
+
signal?: AbortSignal,
|
|
280
|
+
): AzureValidationOptions {
|
|
173
281
|
const validator = options.urlValidator ?? options.customUrlValidator;
|
|
174
282
|
if (!validator) return signal ? withValidationSignal(options, signal) : options;
|
|
175
283
|
const runner = createAzureUrlValidatorRunner(validator as AzureUrlValidator, {
|
|
176
284
|
...(options.urlValidation ?? {}),
|
|
177
285
|
...(options.urlValidatorConcurrency !== undefined ? { concurrency: options.urlValidatorConcurrency } : {}),
|
|
178
|
-
...(options.
|
|
286
|
+
...(options.timeouts?.urlValidationMs !== undefined
|
|
287
|
+
? { timeoutMs: options.timeouts.urlValidationMs }
|
|
288
|
+
: options.urlValidatorTimeoutMs !== undefined
|
|
289
|
+
? { timeoutMs: options.urlValidatorTimeoutMs }
|
|
290
|
+
: {}),
|
|
179
291
|
...(signal ? { signal } : {}),
|
|
180
292
|
...(options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}),
|
|
181
293
|
});
|
|
@@ -206,16 +318,27 @@ export async function synthesizeSsmlSafe(
|
|
|
206
318
|
});
|
|
207
319
|
}
|
|
208
320
|
|
|
321
|
+
const jobScope =
|
|
322
|
+
options.timeouts?.totalJobMs !== undefined
|
|
323
|
+
? createSafeAbortScope(options.signal, options.timeouts.totalJobMs)
|
|
324
|
+
: undefined;
|
|
209
325
|
try {
|
|
210
326
|
return {
|
|
211
327
|
ok: true,
|
|
212
328
|
success: true,
|
|
213
329
|
status: "success",
|
|
214
|
-
value: await client.synthesizeSsml(ssml, {
|
|
330
|
+
value: await client.synthesizeSsml(ssml, {
|
|
331
|
+
signal: jobScope?.signal ?? options.signal,
|
|
332
|
+
timeoutMs: options.timeouts?.perChunkMs,
|
|
333
|
+
timeouts: options.timeouts,
|
|
334
|
+
}),
|
|
215
335
|
};
|
|
216
336
|
} catch (error) {
|
|
337
|
+
if (jobScope?.timedOut()) return failure(toSynthesisError(new Error("Speech synthesis timed out.")));
|
|
217
338
|
const synthesisError = toSynthesisError(error);
|
|
218
339
|
return failure(synthesisError);
|
|
340
|
+
} finally {
|
|
341
|
+
jobScope?.dispose();
|
|
219
342
|
}
|
|
220
343
|
}
|
|
221
344
|
|
|
@@ -225,7 +348,10 @@ export async function synthesizeSsmlChunksSafe(
|
|
|
225
348
|
chunks: readonly (SsmlSynthesisChunk | string)[],
|
|
226
349
|
options: SynthesizeSsmlChunksSafeOptions = {},
|
|
227
350
|
): Promise<SsmlSynthesisChunksSafeResult> {
|
|
228
|
-
const validationOptions = sharedValidationOptions(
|
|
351
|
+
const validationOptions = sharedValidationOptions(
|
|
352
|
+
{ ...(options.validation ?? options), timeouts: options.timeouts },
|
|
353
|
+
options.signal,
|
|
354
|
+
);
|
|
229
355
|
if (options.signal?.aborted) {
|
|
230
356
|
const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
|
|
231
357
|
return failure(error);
|
|
@@ -261,17 +387,20 @@ export async function synthesizeSsmlChunksSafe(
|
|
|
261
387
|
return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
262
388
|
}),
|
|
263
389
|
);
|
|
264
|
-
const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
|
|
265
390
|
if (options.signal?.aborted) {
|
|
266
391
|
const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
|
|
267
392
|
return failure(error);
|
|
268
393
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
394
|
+
const chunkDiagnostics = validations
|
|
395
|
+
.map((diagnostics, chunkIndex) => ({ chunkIndex, diagnostics }))
|
|
396
|
+
.filter((entry) => entry.diagnostics.length > 0);
|
|
397
|
+
if (chunkDiagnostics.length > 0) {
|
|
398
|
+
const error = new BatchChunkValidationError(chunkDiagnostics);
|
|
399
|
+
for (const entry of chunkDiagnostics) pending(entry.chunkIndex, "failed", error);
|
|
272
400
|
return failure(error);
|
|
273
401
|
}
|
|
274
402
|
|
|
403
|
+
let fallbackJobScope: SafeAbortScope | undefined;
|
|
275
404
|
try {
|
|
276
405
|
if (client.synthesizeChunks) {
|
|
277
406
|
const normalizedChunks = chunks.map((chunk) => {
|
|
@@ -283,20 +412,67 @@ export async function synthesizeSsmlChunksSafe(
|
|
|
283
412
|
outputFormat: options.outputFormat,
|
|
284
413
|
signal: options.signal,
|
|
285
414
|
timeoutMs: options.timeoutMs,
|
|
415
|
+
timeouts: options.timeouts,
|
|
286
416
|
sourceNodePath: options.sourceNodePath,
|
|
287
417
|
concurrency: options.concurrency,
|
|
288
418
|
retryOptions: options.retryOptions,
|
|
419
|
+
cancelOnFailure: options.cancelOnFailure,
|
|
420
|
+
resumeChunks: options.resumeChunks,
|
|
421
|
+
resumeChunkIndices: options.resumeChunkIndices,
|
|
422
|
+
customMerger: options.customMerger,
|
|
423
|
+
outputMimeType: options.outputMimeType,
|
|
424
|
+
postMergeValidator: options.postMergeValidator,
|
|
425
|
+
resumeValidation: options.resumeValidation,
|
|
289
426
|
});
|
|
290
427
|
return { ok: true, success: true, status: "success", value };
|
|
291
428
|
}
|
|
429
|
+
const inputs = chunks.map((chunk) => (typeof chunk === "string" ? { ssml: chunk } : chunk));
|
|
430
|
+
const fingerprints = inputs.map((chunk) => computeChunkFingerprint(chunk.ssml, options.outputFormat));
|
|
292
431
|
const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
|
|
293
|
-
|
|
432
|
+
const chunkStates: ChunkExecutionState[] = inputs.map((_chunk, chunkIndex) => ({
|
|
433
|
+
chunkIndex,
|
|
434
|
+
status: "pending",
|
|
435
|
+
canResume: true,
|
|
436
|
+
}));
|
|
437
|
+
const cachedChunks = new Map((options.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
|
|
438
|
+
const invalidCachedIndices = new Set<number>();
|
|
439
|
+
for (const [index, cached] of cachedChunks) {
|
|
440
|
+
if (index < 0 || index >= chunks.length) continue;
|
|
441
|
+
if (options.resumeValidation === "disabled" || cached.fingerprint === fingerprints[index]) {
|
|
442
|
+
results[index] = cached;
|
|
443
|
+
chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: cached };
|
|
444
|
+
} else invalidCachedIndices.add(index);
|
|
445
|
+
}
|
|
446
|
+
const requestedIndices = options.resumeChunkIndices
|
|
447
|
+
? new Set(options.resumeChunkIndices.filter((index) => index >= 0 && index < chunks.length))
|
|
448
|
+
: undefined;
|
|
449
|
+
const shouldSynthesize = (index: number): boolean =>
|
|
450
|
+
(!cachedChunks.has(index) || invalidCachedIndices.has(index)) &&
|
|
451
|
+
(requestedIndices === undefined || requestedIndices.has(index) || invalidCachedIndices.has(index));
|
|
452
|
+
const jobStartedAt = Date.now();
|
|
453
|
+
const jobDeadlineAt =
|
|
454
|
+
options.timeouts?.totalJobMs !== undefined && options.timeouts.totalJobMs > 0
|
|
455
|
+
? jobStartedAt + options.timeouts.totalJobMs
|
|
456
|
+
: undefined;
|
|
457
|
+
const jobScope =
|
|
458
|
+
chunks.length > 1 || options.timeouts?.totalJobMs !== undefined
|
|
459
|
+
? createSafeAbortScope(options.signal, options.timeouts?.totalJobMs)
|
|
460
|
+
: undefined;
|
|
461
|
+
fallbackJobScope = jobScope;
|
|
462
|
+
const failedIndices = new Set<number>();
|
|
463
|
+
let firstError: unknown;
|
|
464
|
+
let completed = [...results].filter((result) => result !== undefined).length;
|
|
294
465
|
let nextIndex = 0;
|
|
295
466
|
const concurrency = resolveConcurrency(options.concurrency, chunks.length);
|
|
296
467
|
const worker = async (): Promise<void> => {
|
|
297
468
|
while (true) {
|
|
298
469
|
const index = nextIndex++;
|
|
299
470
|
if (index >= chunks.length) return;
|
|
471
|
+
if (!shouldSynthesize(index)) continue;
|
|
472
|
+
if (firstError && options.cancelOnFailure !== false) {
|
|
473
|
+
chunkStates[index] = { chunkIndex: index, status: "cancelled", isOriginalFailure: false, canResume: true };
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
300
476
|
const chunk = chunks[index];
|
|
301
477
|
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
302
478
|
const sourceNodePath = input.sourceNodePath;
|
|
@@ -304,30 +480,46 @@ export async function synthesizeSsmlChunksSafe(
|
|
|
304
480
|
pending(index, "synthesizing");
|
|
305
481
|
const startedAt = Date.now();
|
|
306
482
|
try {
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
483
|
+
const chunkTimeout = options.timeouts?.chunkWithRetriesMs ?? options.timeouts?.perChunkMs;
|
|
484
|
+
const chunkScope =
|
|
485
|
+
chunkTimeout !== undefined || jobScope
|
|
486
|
+
? createSafeAbortScope(jobScope?.signal ?? options.signal, chunkTimeout ?? options.timeoutMs)
|
|
487
|
+
: undefined;
|
|
488
|
+
const chunkSignal = chunkScope?.signal ?? options.signal;
|
|
489
|
+
let result: SsmlSynthesisResult;
|
|
490
|
+
try {
|
|
491
|
+
result = await retryableSynthesis(
|
|
492
|
+
() =>
|
|
493
|
+
client.synthesizeSsml(input.ssml, {
|
|
494
|
+
outputFormat: options.outputFormat,
|
|
495
|
+
signal: chunkSignal,
|
|
496
|
+
timeoutMs: options.timeouts?.perChunkMs ?? options.timeoutMs,
|
|
497
|
+
sourceNodePath: input.sourceNodePath ?? options.sourceNodePath,
|
|
498
|
+
}),
|
|
499
|
+
options.retryOptions,
|
|
500
|
+
chunkSignal,
|
|
501
|
+
(retryAttempt, nextRetryDelayMs) =>
|
|
502
|
+
options.onProgress?.({
|
|
503
|
+
currentChunk: completed,
|
|
504
|
+
totalChunks: chunks.length,
|
|
505
|
+
percent: chunks.length === 0 ? 100 : Math.round((completed / chunks.length) * 100),
|
|
506
|
+
chunkIndex: index,
|
|
507
|
+
originalTextRange: input.originalTextRange,
|
|
508
|
+
status: "synthesizing",
|
|
509
|
+
durationMs: Date.now() - startedAt,
|
|
510
|
+
retryAttempt,
|
|
511
|
+
nextRetryDelayMs,
|
|
512
|
+
isRetrying: true,
|
|
513
|
+
}),
|
|
514
|
+
jobDeadlineAt,
|
|
515
|
+
);
|
|
516
|
+
} catch (error) {
|
|
517
|
+
if (chunkScope?.timedOut())
|
|
518
|
+
throw new Error(`Speech synthesis timed out after ${chunkTimeout ?? options.timeoutMs} ms.`);
|
|
519
|
+
throw error;
|
|
520
|
+
} finally {
|
|
521
|
+
chunkScope?.dispose();
|
|
522
|
+
}
|
|
331
523
|
results[index] = {
|
|
332
524
|
...result,
|
|
333
525
|
...(input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {}),
|
|
@@ -397,6 +589,7 @@ export async function synthesizeSsmlChunksSafe(
|
|
|
397
589
|
}
|
|
398
590
|
: {}),
|
|
399
591
|
};
|
|
592
|
+
chunkStates[index] = { chunkIndex: index, status: "succeeded", canResume: true, result: results[index] };
|
|
400
593
|
completed += 1;
|
|
401
594
|
options.onProgress?.({
|
|
402
595
|
currentChunk: completed,
|
|
@@ -408,6 +601,16 @@ export async function synthesizeSsmlChunksSafe(
|
|
|
408
601
|
durationMs: Date.now() - startedAt,
|
|
409
602
|
});
|
|
410
603
|
} catch (error) {
|
|
604
|
+
const wasCancelled = firstError !== undefined || Boolean(jobScope?.signal.aborted && !jobScope?.timedOut());
|
|
605
|
+
firstError ??= error;
|
|
606
|
+
if (!wasCancelled) failedIndices.add(index);
|
|
607
|
+
chunkStates[index] = {
|
|
608
|
+
chunkIndex: index,
|
|
609
|
+
status: wasCancelled ? "cancelled" : "failed",
|
|
610
|
+
isOriginalFailure: !wasCancelled,
|
|
611
|
+
canResume: true,
|
|
612
|
+
error: error as ChunkExecutionState["error"],
|
|
613
|
+
};
|
|
411
614
|
options.onProgress?.({
|
|
412
615
|
currentChunk: completed,
|
|
413
616
|
totalChunks: chunks.length,
|
|
@@ -418,24 +621,59 @@ export async function synthesizeSsmlChunksSafe(
|
|
|
418
621
|
durationMs: Date.now() - startedAt,
|
|
419
622
|
error,
|
|
420
623
|
});
|
|
421
|
-
|
|
624
|
+
if (options.cancelOnFailure !== false) jobScope?.abort();
|
|
625
|
+
return;
|
|
422
626
|
}
|
|
423
627
|
}
|
|
424
628
|
};
|
|
425
629
|
await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
|
|
630
|
+
if (firstError && options.cancelOnFailure !== false) {
|
|
631
|
+
for (const [chunkIndex, state] of chunkStates.entries()) {
|
|
632
|
+
if (state.status === "pending" && shouldSynthesize(chunkIndex)) {
|
|
633
|
+
chunkStates[chunkIndex] = { chunkIndex, status: "cancelled", isOriginalFailure: false, canResume: true };
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (failedIndices.size > 0) {
|
|
638
|
+
const error = firstError ?? new Error("One or more SSML chunks failed to synthesize.");
|
|
639
|
+
const synthesizedChunks = results.flatMap((result, chunkIndex) =>
|
|
640
|
+
result ? [{ ...result, chunkIndex, fingerprint: fingerprints[chunkIndex] ?? "" }] : [],
|
|
641
|
+
);
|
|
642
|
+
(error as { partialResult?: PartialChunkSynthesisResult }).partialResult = {
|
|
643
|
+
synthesizedChunks,
|
|
644
|
+
completedChunks: synthesizedChunks,
|
|
645
|
+
pendingChunkIndices: chunkStates.flatMap((state) =>
|
|
646
|
+
state.status === "pending" || state.status === "cancelled" || state.status === "failed"
|
|
647
|
+
? [state.chunkIndex]
|
|
648
|
+
: [],
|
|
649
|
+
),
|
|
650
|
+
failedChunkIndices: [...failedIndices],
|
|
651
|
+
cancelledChunkIndices: chunkStates
|
|
652
|
+
.filter((state) => state.status === "cancelled")
|
|
653
|
+
.map((state) => state.chunkIndex),
|
|
654
|
+
chunkStates,
|
|
655
|
+
totalChunks: chunks.length,
|
|
656
|
+
};
|
|
657
|
+
throw error;
|
|
658
|
+
}
|
|
426
659
|
const orderedResults = results.filter((result): result is SsmlSynthesisResult => result !== undefined);
|
|
427
660
|
return {
|
|
428
661
|
ok: true,
|
|
429
662
|
success: true,
|
|
430
663
|
status: "success",
|
|
431
|
-
value: mergeSynthesisResults(orderedResults, {
|
|
664
|
+
value: await mergeSynthesisResults(orderedResults, {
|
|
432
665
|
format: (options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3") as AzureTtsOutputFormat,
|
|
433
|
-
signal: options.signal,
|
|
666
|
+
signal: jobScope?.signal ?? options.signal,
|
|
667
|
+
customMerger: options.customMerger,
|
|
668
|
+
outputMimeType: options.outputMimeType,
|
|
669
|
+
postMergeValidator: options.postMergeValidator,
|
|
434
670
|
}),
|
|
435
671
|
};
|
|
436
672
|
} catch (error) {
|
|
437
673
|
const synthesisError = toSynthesisError(error);
|
|
438
|
-
return failure(synthesisError);
|
|
674
|
+
return failure(synthesisError, partialResultFrom(error));
|
|
675
|
+
} finally {
|
|
676
|
+
fallbackJobScope?.dispose();
|
|
439
677
|
}
|
|
440
678
|
}
|
|
441
679
|
|