@ssml-builder-js/azure-tts-client 2.16.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 +11 -0
- package/dist/index.d.mts +77 -10
- package/dist/index.d.ts +77 -10
- package/dist/index.js +330 -70
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +328 -70
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +13 -4
- package/src/errors.ts +44 -2
- package/src/index.ts +15 -2
- package/src/safe.ts +210 -41
- package/src/synthesis.ts +180 -30
- package/src/types.ts +58 -0
- package/src/voiceCatalog.ts +4 -0
- package/test/v217-pipeline.test.ts +125 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssml-builder-js/azure-tts-client",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.17.0",
|
|
4
4
|
"description": "Azure Text-to-Speech client using the Microsoft Speech SDK",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"typescript": "^6.0.3"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@ssml-builder-js/ssml-core": "^2.
|
|
38
|
+
"@ssml-builder-js/ssml-core": "^2.17.0",
|
|
39
39
|
"microsoft-cognitiveservices-speech-sdk": "1.51.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
package/src/client.ts
CHANGED
|
@@ -19,16 +19,16 @@ export class AzureTtsClient {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
async synthesize(ssml: string): Promise<ArrayBuffer> {
|
|
22
|
-
const { region, subscriptionKey, outputFormat, signal, timeoutMs } = this.#options;
|
|
22
|
+
const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = this.#options;
|
|
23
23
|
const endpoint = this.#options.endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
|
|
24
24
|
this.#options.logger?.debug?.("Using Azure TTS endpoint:", endpoint);
|
|
25
25
|
|
|
26
|
-
const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs };
|
|
26
|
+
const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts };
|
|
27
27
|
return synthesizeSpeech(ssml, config);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
async synthesizeSsml(ssml: string, options: Partial<TtsConfig> = {}): Promise<SsmlSynthesisResult> {
|
|
31
|
-
const { region, subscriptionKey, outputFormat, signal, timeoutMs } = this.#options;
|
|
31
|
+
const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = this.#options;
|
|
32
32
|
const endpoint = this.#options.endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
|
|
33
33
|
this.#options.logger?.debug?.("Using Azure TTS endpoint:", endpoint);
|
|
34
34
|
|
|
@@ -39,6 +39,7 @@ export class AzureTtsClient {
|
|
|
39
39
|
outputFormat: options.outputFormat ?? outputFormat,
|
|
40
40
|
signal: options.signal ?? signal,
|
|
41
41
|
timeoutMs: options.timeoutMs ?? timeoutMs,
|
|
42
|
+
timeouts: options.timeouts ?? timeouts,
|
|
42
43
|
sourceNodePath: options.sourceNodePath,
|
|
43
44
|
sourceTextSegments: options.sourceTextSegments,
|
|
44
45
|
sourceMarkers: options.sourceMarkers,
|
|
@@ -49,7 +50,7 @@ export class AzureTtsClient {
|
|
|
49
50
|
chunks: readonly (SsmlSynthesisChunk | string)[],
|
|
50
51
|
options: SynthesizeChunksOptions = {},
|
|
51
52
|
): Promise<SsmlSynthesisResult> {
|
|
52
|
-
const { region, subscriptionKey, outputFormat, signal, timeoutMs } = this.#options;
|
|
53
|
+
const { region, subscriptionKey, outputFormat, signal, timeoutMs, timeouts } = this.#options;
|
|
53
54
|
const endpoint = this.#options.endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
|
|
54
55
|
return synthesizeSsmlChunks(chunks, {
|
|
55
56
|
endpoint,
|
|
@@ -58,10 +59,17 @@ export class AzureTtsClient {
|
|
|
58
59
|
outputFormat: options.outputFormat ?? outputFormat,
|
|
59
60
|
signal: options.signal ?? signal,
|
|
60
61
|
timeoutMs: options.timeoutMs ?? timeoutMs,
|
|
62
|
+
timeouts: options.timeouts ?? timeouts,
|
|
61
63
|
sourceNodePath: options.sourceNodePath,
|
|
62
64
|
onProgress: options.onProgress ?? this.#options.onProgress,
|
|
63
65
|
concurrency: options.concurrency ?? this.#options.concurrency,
|
|
64
66
|
retryOptions: options.retryOptions ?? this.#options.retryOptions,
|
|
67
|
+
cancelOnFailure: options.cancelOnFailure,
|
|
68
|
+
resumeChunks: options.resumeChunks,
|
|
69
|
+
resumeChunkIndices: options.resumeChunkIndices,
|
|
70
|
+
customMerger: options.customMerger,
|
|
71
|
+
outputMimeType: options.outputMimeType,
|
|
72
|
+
postMergeValidator: options.postMergeValidator,
|
|
65
73
|
});
|
|
66
74
|
}
|
|
67
75
|
|
|
@@ -78,6 +86,7 @@ export class AzureTtsClient {
|
|
|
78
86
|
outputFormat: options.outputFormat ?? this.#options.outputFormat,
|
|
79
87
|
signal: options.signal ?? this.#options.signal,
|
|
80
88
|
timeoutMs: options.timeoutMs ?? this.#options.timeoutMs,
|
|
89
|
+
timeouts: options.timeouts ?? this.#options.timeouts,
|
|
81
90
|
onProgress: options.onProgress ?? this.#options.onProgress,
|
|
82
91
|
concurrency: options.concurrency ?? this.#options.concurrency,
|
|
83
92
|
retryOptions: options.retryOptions ?? this.#options.retryOptions,
|
package/src/errors.ts
CHANGED
|
@@ -13,15 +13,57 @@ export class AzureTtsError extends Error {
|
|
|
13
13
|
readonly statusText: string;
|
|
14
14
|
readonly responseBody: string;
|
|
15
15
|
readonly requestId: string | null;
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
readonly retryAfterMs?: number;
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
status: number,
|
|
20
|
+
statusText: string,
|
|
21
|
+
responseBody: string,
|
|
22
|
+
requestId: string | null,
|
|
23
|
+
responseHeaders?: Headers | Readonly<Record<string, string>>,
|
|
24
|
+
) {
|
|
18
25
|
super(`Azure TTS request failed: ${status} ${statusText}`);
|
|
19
26
|
this.name = "AzureTtsError";
|
|
20
27
|
this.status = status;
|
|
21
28
|
this.statusText = statusText;
|
|
22
29
|
this.responseBody = responseBody;
|
|
23
30
|
this.requestId = requestId;
|
|
31
|
+
const value =
|
|
32
|
+
responseHeaders instanceof Headers
|
|
33
|
+
? responseHeaders.get("retry-after")
|
|
34
|
+
: (responseHeaders?.["retry-after"] ?? responseHeaders?.["Retry-After"]);
|
|
35
|
+
const seconds = value ? Number(value.trim()) : NaN;
|
|
36
|
+
const date = value ? Date.parse(value) : NaN;
|
|
37
|
+
if (Number.isFinite(seconds) && seconds >= 0) this.retryAfterMs = seconds * 1000;
|
|
38
|
+
else if (Number.isFinite(date)) this.retryAfterMs = Math.max(0, date - Date.now());
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Reads Retry-After from an error-like value, returning milliseconds when present. */
|
|
43
|
+
export function getRetryAfterDelayMs(error: unknown): number | undefined {
|
|
44
|
+
if (error instanceof AzureTtsError && error.retryAfterMs !== undefined) return error.retryAfterMs;
|
|
45
|
+
if (!error || typeof error !== "object") return undefined;
|
|
46
|
+
const candidate = error as { retryAfterMs?: unknown; headers?: unknown; response?: unknown };
|
|
47
|
+
if (typeof candidate.retryAfterMs === "number" && candidate.retryAfterMs >= 0) return candidate.retryAfterMs;
|
|
48
|
+
const headers = candidate.headers ?? (candidate.response as { headers?: unknown } | undefined)?.headers;
|
|
49
|
+
if (headers instanceof Headers) {
|
|
50
|
+
const value = headers.get("retry-after");
|
|
51
|
+
if (!value) return undefined;
|
|
52
|
+
const seconds = Number(value.trim());
|
|
53
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
|
|
54
|
+
const date = Date.parse(value);
|
|
55
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
|
|
56
|
+
}
|
|
57
|
+
if (headers && typeof headers === "object") {
|
|
58
|
+
const value =
|
|
59
|
+
(headers as Record<string, unknown>)["retry-after"] ?? (headers as Record<string, unknown>)["Retry-After"];
|
|
60
|
+
if (typeof value !== "string") return undefined;
|
|
61
|
+
const seconds = Number(value.trim());
|
|
62
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
|
|
63
|
+
const date = Date.parse(value);
|
|
64
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
|
|
24
65
|
}
|
|
66
|
+
return undefined;
|
|
25
67
|
}
|
|
26
68
|
|
|
27
69
|
export class AzureTtsSdkError extends AzureTtsError {
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,13 @@ export type {
|
|
|
17
17
|
SynthesizeChunksOptions,
|
|
18
18
|
SynthesisChunkStatus,
|
|
19
19
|
RetryOptions,
|
|
20
|
+
SynthesisTimeouts,
|
|
21
|
+
SynthesizedChunk,
|
|
22
|
+
PartialChunkSynthesisResult,
|
|
23
|
+
PartialSynthesisResult,
|
|
24
|
+
CustomMergerContext,
|
|
25
|
+
CustomAudioMerger,
|
|
26
|
+
PostMergeValidator,
|
|
20
27
|
TtsConfig,
|
|
21
28
|
} from "./types.ts";
|
|
22
29
|
export {
|
|
@@ -27,6 +34,7 @@ export {
|
|
|
27
34
|
SynthesisCancelledError,
|
|
28
35
|
SynthesisTimeoutError,
|
|
29
36
|
UnsupportedMergeFormatError,
|
|
37
|
+
getRetryAfterDelayMs,
|
|
30
38
|
} from "./errors.ts";
|
|
31
39
|
export type { AzureTtsSynthesisError, SynthesisErrorKind } from "./errors.ts";
|
|
32
40
|
export { AzureTtsClient } from "./client.ts";
|
|
@@ -41,7 +49,6 @@ export {
|
|
|
41
49
|
synthesizeSsmlChunks,
|
|
42
50
|
} from "./synthesis.ts";
|
|
43
51
|
export type {
|
|
44
|
-
CustomMergerContext,
|
|
45
52
|
InputAudioSpecs,
|
|
46
53
|
MergeAudioFormat,
|
|
47
54
|
MergeAudioOptions,
|
|
@@ -49,7 +56,12 @@ export type {
|
|
|
49
56
|
} from "./synthesis.ts";
|
|
50
57
|
export { DEFAULT_OUTPUT_FORMAT, resolveMimeType } from "./outputFormats.ts";
|
|
51
58
|
export type { AzureTtsOutputFormat } from "./outputFormats.ts";
|
|
52
|
-
export {
|
|
59
|
+
export {
|
|
60
|
+
BatchChunkValidationError,
|
|
61
|
+
ChunkValidationError,
|
|
62
|
+
synthesizeSsmlChunksSafe,
|
|
63
|
+
synthesizeSsmlSafe,
|
|
64
|
+
} from "./safe.ts";
|
|
53
65
|
export type {
|
|
54
66
|
AzureApiErrorResult,
|
|
55
67
|
Result,
|
|
@@ -62,6 +74,7 @@ export type {
|
|
|
62
74
|
SsmlSynthesisChunksSafeResult,
|
|
63
75
|
SynthesizeSsmlChunksSafeOptions,
|
|
64
76
|
ValidationErrorResult,
|
|
77
|
+
ChunkDiagnostics,
|
|
65
78
|
} from "./safe.ts";
|
|
66
79
|
export { fetchAzureVoiceCatalog } from "./voiceCatalog.ts";
|
|
67
80
|
export type {
|
package/src/safe.ts
CHANGED
|
@@ -10,6 +10,7 @@ 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
16
|
import { mergeSynthesisResults } from "./synthesis.ts";
|
|
@@ -19,6 +20,11 @@ import type {
|
|
|
19
20
|
SynthesisProgressEvent,
|
|
20
21
|
SynthesizeChunksOptions,
|
|
21
22
|
RetryOptions,
|
|
23
|
+
SynthesisTimeouts,
|
|
24
|
+
SynthesizedChunk,
|
|
25
|
+
PartialChunkSynthesisResult,
|
|
26
|
+
CustomAudioMerger,
|
|
27
|
+
PostMergeValidator,
|
|
22
28
|
} from "./types.ts";
|
|
23
29
|
import type { AzureTtsOutputFormat } from "./outputFormats.ts";
|
|
24
30
|
|
|
@@ -43,6 +49,29 @@ export class ChunkValidationError extends Error {
|
|
|
43
49
|
}
|
|
44
50
|
}
|
|
45
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
|
+
|
|
46
75
|
export type Result<T, E> =
|
|
47
76
|
| { readonly ok: true; readonly success: true; readonly status: "success"; readonly value: T }
|
|
48
77
|
| (E extends { readonly kind: infer Kind extends SynthesisErrorKind }
|
|
@@ -51,18 +80,29 @@ export type Result<T, E> =
|
|
|
51
80
|
readonly success: false;
|
|
52
81
|
readonly status: Kind;
|
|
53
82
|
readonly error: E;
|
|
83
|
+
readonly partialResult?: PartialChunkSynthesisResult;
|
|
54
84
|
}
|
|
55
85
|
: {
|
|
56
86
|
readonly ok: false;
|
|
57
87
|
readonly success: false;
|
|
58
88
|
readonly status: SynthesisErrorKind;
|
|
59
89
|
readonly error: E;
|
|
90
|
+
readonly partialResult?: PartialChunkSynthesisResult;
|
|
60
91
|
});
|
|
61
92
|
|
|
62
93
|
export type SynthesisResult<T, E> = Result<T, E>;
|
|
63
94
|
|
|
64
|
-
function failure<E extends { readonly kind: SynthesisErrorKind }>(
|
|
65
|
-
|
|
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>;
|
|
66
106
|
}
|
|
67
107
|
|
|
68
108
|
export type Success<T> = Extract<Result<T, never>, { readonly ok: true }>;
|
|
@@ -81,6 +121,7 @@ export interface SynthesizeSsmlSafeOptions extends AzureValidationOptions {
|
|
|
81
121
|
/** Optional nested form for callers that want to keep validation settings grouped. */
|
|
82
122
|
validation?: AzureValidationOptions;
|
|
83
123
|
signal?: AbortSignal;
|
|
124
|
+
timeouts?: SynthesisTimeouts;
|
|
84
125
|
}
|
|
85
126
|
|
|
86
127
|
export interface SynthesizeSsmlChunksSafeOptions extends AzureValidationOptions {
|
|
@@ -88,15 +129,23 @@ export interface SynthesizeSsmlChunksSafeOptions extends AzureValidationOptions
|
|
|
88
129
|
outputFormat?: string;
|
|
89
130
|
signal?: AbortSignal;
|
|
90
131
|
timeoutMs?: number;
|
|
132
|
+
timeouts?: SynthesisTimeouts;
|
|
91
133
|
sourceNodePath?: string[];
|
|
92
134
|
onProgress?: (event: SynthesisProgressEvent) => void;
|
|
93
135
|
concurrency?: number;
|
|
94
136
|
retryOptions?: RetryOptions;
|
|
137
|
+
cancelOnFailure?: boolean;
|
|
138
|
+
resumeChunks?: readonly SynthesizedChunk[];
|
|
139
|
+
resumeChunkIndices?: readonly number[];
|
|
140
|
+
customMerger?: CustomAudioMerger;
|
|
141
|
+
outputMimeType?: string;
|
|
142
|
+
postMergeValidator?: PostMergeValidator;
|
|
95
143
|
}
|
|
96
144
|
|
|
97
145
|
export type SsmlSynthesisChunksSafeResult =
|
|
98
146
|
| Result<SsmlSynthesisResult, never>
|
|
99
147
|
| Result<never, ChunkValidationError>
|
|
148
|
+
| Result<never, BatchChunkValidationError>
|
|
100
149
|
| Result<never, SsmlSynthesisError | ChunkValidationError>;
|
|
101
150
|
|
|
102
151
|
interface SynthesisClient {
|
|
@@ -107,6 +156,44 @@ interface SynthesisClient {
|
|
|
107
156
|
): Promise<SsmlSynthesisResult>;
|
|
108
157
|
}
|
|
109
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
|
+
|
|
110
197
|
function isRetryable(error: unknown): boolean {
|
|
111
198
|
if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
|
|
112
199
|
const status =
|
|
@@ -123,6 +210,10 @@ function delayForRetry(options: RetryOptions, attempt: number): number {
|
|
|
123
210
|
return Math.floor(Math.random() * (base + 1));
|
|
124
211
|
}
|
|
125
212
|
|
|
213
|
+
function retryDelayForError(options: RetryOptions, attempt: number, error: unknown): number {
|
|
214
|
+
return getRetryAfterDelayMs(error) ?? delayForRetry(options, attempt);
|
|
215
|
+
}
|
|
216
|
+
|
|
126
217
|
function resolveConcurrency(value: number | undefined, total: number): number {
|
|
127
218
|
if (value === undefined) return 1;
|
|
128
219
|
if (value === Infinity) return Math.max(1, total);
|
|
@@ -140,6 +231,7 @@ async function retryableSynthesis(
|
|
|
140
231
|
maxRetries: Math.max(0, Math.floor(options.maxRetries)),
|
|
141
232
|
initialDelayMs: options.initialDelayMs,
|
|
142
233
|
maxDelayMs: options.maxDelayMs,
|
|
234
|
+
shouldRetry: options.shouldRetry,
|
|
143
235
|
}
|
|
144
236
|
: undefined;
|
|
145
237
|
let attempt = 0;
|
|
@@ -148,9 +240,10 @@ async function retryableSynthesis(
|
|
|
148
240
|
try {
|
|
149
241
|
return await synthesize();
|
|
150
242
|
} catch (error) {
|
|
151
|
-
if (!retry || attempt >= retry.maxRetries || !
|
|
243
|
+
if (!retry || attempt >= retry.maxRetries || !(retry.shouldRetry?.(error, attempt + 1) ?? isRetryable(error)))
|
|
244
|
+
throw error;
|
|
152
245
|
attempt += 1;
|
|
153
|
-
const delayMs =
|
|
246
|
+
const delayMs = retryDelayForError(retry, attempt, error);
|
|
154
247
|
onRetry(attempt, delayMs);
|
|
155
248
|
if (delayMs > 0)
|
|
156
249
|
await new Promise<void>((resolve, reject) => {
|
|
@@ -169,13 +262,20 @@ async function retryableSynthesis(
|
|
|
169
262
|
}
|
|
170
263
|
}
|
|
171
264
|
|
|
172
|
-
function sharedValidationOptions(
|
|
265
|
+
function sharedValidationOptions(
|
|
266
|
+
options: AzureValidationOptions & { timeouts?: SynthesisTimeouts },
|
|
267
|
+
signal?: AbortSignal,
|
|
268
|
+
): AzureValidationOptions {
|
|
173
269
|
const validator = options.urlValidator ?? options.customUrlValidator;
|
|
174
270
|
if (!validator) return signal ? withValidationSignal(options, signal) : options;
|
|
175
271
|
const runner = createAzureUrlValidatorRunner(validator as AzureUrlValidator, {
|
|
176
272
|
...(options.urlValidation ?? {}),
|
|
177
273
|
...(options.urlValidatorConcurrency !== undefined ? { concurrency: options.urlValidatorConcurrency } : {}),
|
|
178
|
-
...(options.
|
|
274
|
+
...(options.timeouts?.urlValidationMs !== undefined
|
|
275
|
+
? { timeoutMs: options.timeouts.urlValidationMs }
|
|
276
|
+
: options.urlValidatorTimeoutMs !== undefined
|
|
277
|
+
? { timeoutMs: options.urlValidatorTimeoutMs }
|
|
278
|
+
: {}),
|
|
179
279
|
...(signal ? { signal } : {}),
|
|
180
280
|
...(options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}),
|
|
181
281
|
});
|
|
@@ -211,7 +311,11 @@ export async function synthesizeSsmlSafe(
|
|
|
211
311
|
ok: true,
|
|
212
312
|
success: true,
|
|
213
313
|
status: "success",
|
|
214
|
-
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
|
+
}),
|
|
215
319
|
};
|
|
216
320
|
} catch (error) {
|
|
217
321
|
const synthesisError = toSynthesisError(error);
|
|
@@ -225,7 +329,10 @@ export async function synthesizeSsmlChunksSafe(
|
|
|
225
329
|
chunks: readonly (SsmlSynthesisChunk | string)[],
|
|
226
330
|
options: SynthesizeSsmlChunksSafeOptions = {},
|
|
227
331
|
): Promise<SsmlSynthesisChunksSafeResult> {
|
|
228
|
-
const validationOptions = sharedValidationOptions(
|
|
332
|
+
const validationOptions = sharedValidationOptions(
|
|
333
|
+
{ ...(options.validation ?? options), timeouts: options.timeouts },
|
|
334
|
+
options.signal,
|
|
335
|
+
);
|
|
229
336
|
if (options.signal?.aborted) {
|
|
230
337
|
const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
|
|
231
338
|
return failure(error);
|
|
@@ -261,17 +368,20 @@ export async function synthesizeSsmlChunksSafe(
|
|
|
261
368
|
return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
262
369
|
}),
|
|
263
370
|
);
|
|
264
|
-
const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
|
|
265
371
|
if (options.signal?.aborted) {
|
|
266
372
|
const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
|
|
267
373
|
return failure(error);
|
|
268
374
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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);
|
|
272
381
|
return failure(error);
|
|
273
382
|
}
|
|
274
383
|
|
|
384
|
+
let fallbackJobScope: SafeAbortScope | undefined;
|
|
275
385
|
try {
|
|
276
386
|
if (client.synthesizeChunks) {
|
|
277
387
|
const normalizedChunks = chunks.map((chunk) => {
|
|
@@ -283,20 +393,45 @@ export async function synthesizeSsmlChunksSafe(
|
|
|
283
393
|
outputFormat: options.outputFormat,
|
|
284
394
|
signal: options.signal,
|
|
285
395
|
timeoutMs: options.timeoutMs,
|
|
396
|
+
timeouts: options.timeouts,
|
|
286
397
|
sourceNodePath: options.sourceNodePath,
|
|
287
398
|
concurrency: options.concurrency,
|
|
288
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,
|
|
289
406
|
});
|
|
290
407
|
return { ok: true, success: true, status: "success", value };
|
|
291
408
|
}
|
|
292
409
|
const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
|
|
293
|
-
|
|
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;
|
|
294
427
|
let nextIndex = 0;
|
|
295
428
|
const concurrency = resolveConcurrency(options.concurrency, chunks.length);
|
|
296
429
|
const worker = async (): Promise<void> => {
|
|
297
430
|
while (true) {
|
|
298
431
|
const index = nextIndex++;
|
|
299
432
|
if (index >= chunks.length) return;
|
|
433
|
+
if (!shouldSynthesize(index)) continue;
|
|
434
|
+
if (failedIndices.size > 0 && options.cancelOnFailure !== false) return;
|
|
300
435
|
const chunk = chunks[index];
|
|
301
436
|
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
302
437
|
const sourceNodePath = input.sourceNodePath;
|
|
@@ -304,30 +439,45 @@ export async function synthesizeSsmlChunksSafe(
|
|
|
304
439
|
pending(index, "synthesizing");
|
|
305
440
|
const startedAt = Date.now();
|
|
306
441
|
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
|
-
|
|
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
|
+
}
|
|
331
481
|
results[index] = {
|
|
332
482
|
...result,
|
|
333
483
|
...(input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {}),
|
|
@@ -408,6 +558,7 @@ export async function synthesizeSsmlChunksSafe(
|
|
|
408
558
|
durationMs: Date.now() - startedAt,
|
|
409
559
|
});
|
|
410
560
|
} catch (error) {
|
|
561
|
+
failedIndices.add(index);
|
|
411
562
|
options.onProgress?.({
|
|
412
563
|
currentChunk: completed,
|
|
413
564
|
totalChunks: chunks.length,
|
|
@@ -418,24 +569,42 @@ export async function synthesizeSsmlChunksSafe(
|
|
|
418
569
|
durationMs: Date.now() - startedAt,
|
|
419
570
|
error,
|
|
420
571
|
});
|
|
421
|
-
|
|
572
|
+
if (options.cancelOnFailure !== false) jobScope?.abort();
|
|
573
|
+
firstError ??= error;
|
|
574
|
+
return;
|
|
422
575
|
}
|
|
423
576
|
}
|
|
424
577
|
};
|
|
425
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;
|
|
589
|
+
}
|
|
426
590
|
const orderedResults = results.filter((result): result is SsmlSynthesisResult => result !== undefined);
|
|
427
591
|
return {
|
|
428
592
|
ok: true,
|
|
429
593
|
success: true,
|
|
430
594
|
status: "success",
|
|
431
|
-
value: mergeSynthesisResults(orderedResults, {
|
|
595
|
+
value: await mergeSynthesisResults(orderedResults, {
|
|
432
596
|
format: (options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3") as AzureTtsOutputFormat,
|
|
433
|
-
signal: options.signal,
|
|
597
|
+
signal: jobScope?.signal ?? options.signal,
|
|
598
|
+
customMerger: options.customMerger,
|
|
599
|
+
outputMimeType: options.outputMimeType,
|
|
600
|
+
postMergeValidator: options.postMergeValidator,
|
|
434
601
|
}),
|
|
435
602
|
};
|
|
436
603
|
} catch (error) {
|
|
437
604
|
const synthesisError = toSynthesisError(error);
|
|
438
|
-
return failure(synthesisError);
|
|
605
|
+
return failure(synthesisError, partialResultFrom(error));
|
|
606
|
+
} finally {
|
|
607
|
+
fallbackJobScope?.dispose();
|
|
439
608
|
}
|
|
440
609
|
}
|
|
441
610
|
|