@ssml-builder-js/azure-tts-client 2.14.0 → 2.16.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/index.ts CHANGED
@@ -5,6 +5,9 @@
5
5
  export type {
6
6
  AzureTtsClientOptions,
7
7
  AzureTtsLogger,
8
+ AudioSpecification,
9
+ MappingStatus,
10
+ MergedSynthesisResult,
8
11
  SsmlSynthesisBookmark,
9
12
  SsmlSynthesisBoundary,
10
13
  SsmlSynthesisResult,
@@ -13,9 +16,19 @@ export type {
13
16
  SynthesisProgressEvent,
14
17
  SynthesizeChunksOptions,
15
18
  SynthesisChunkStatus,
19
+ RetryOptions,
16
20
  TtsConfig,
17
21
  } from "./types.ts";
18
- export { AzureTtsError, AzureTtsSdkError, UnsupportedMergeFormatError } from "./errors.ts";
22
+ export {
23
+ AzureTtsError,
24
+ AzureTtsSdkError,
25
+ AudioFormatMismatchError,
26
+ MergeError,
27
+ SynthesisCancelledError,
28
+ SynthesisTimeoutError,
29
+ UnsupportedMergeFormatError,
30
+ } from "./errors.ts";
31
+ export type { AzureTtsSynthesisError, SynthesisErrorKind } from "./errors.ts";
19
32
  export { AzureTtsClient } from "./client.ts";
20
33
  export { synthesizeSpeech } from "./synthesis.ts";
21
34
  export { synthesizeSsml } from "./synthesis.ts";
@@ -23,15 +36,25 @@ export {
23
36
  canMergeAudioFormat,
24
37
  mergeAudioBuffers,
25
38
  mergeSynthesisResults,
39
+ inspectAudioSpecification,
26
40
  resolveMergeAudioFormat,
27
41
  synthesizeSsmlChunks,
28
42
  } from "./synthesis.ts";
29
- export type { MergeAudioFormat } from "./synthesis.ts";
43
+ export type {
44
+ CustomMergerContext,
45
+ InputAudioSpecs,
46
+ MergeAudioFormat,
47
+ MergeAudioOptions,
48
+ MergeSynthesisOptions,
49
+ } from "./synthesis.ts";
50
+ export { DEFAULT_OUTPUT_FORMAT, resolveMimeType } from "./outputFormats.ts";
51
+ export type { AzureTtsOutputFormat } from "./outputFormats.ts";
30
52
  export { ChunkValidationError, synthesizeSsmlChunksSafe, synthesizeSsmlSafe } from "./safe.ts";
31
53
  export type {
32
54
  AzureApiErrorResult,
33
55
  Result,
34
56
  SsmlSynthesisSafeResult,
57
+ SsmlSynthesisError,
35
58
  SsmlValidationError as AzureSsmlValidationError,
36
59
  Success,
37
60
  SynthesisResult,
@@ -2,7 +2,7 @@ import * as SpeechSDK from "microsoft-cognitiveservices-speech-sdk";
2
2
 
3
3
  export const DEFAULT_OUTPUT_FORMAT = "audio-16khz-128kbitrate-mono-mp3";
4
4
 
5
- const OUTPUT_FORMATS: Record<string, SpeechSDK.SpeechSynthesisOutputFormat> = {
5
+ const OUTPUT_FORMATS = {
6
6
  "raw-8khz-8bit-mono-mulaw": SpeechSDK.SpeechSynthesisOutputFormat.Raw8Khz8BitMonoMULaw,
7
7
  "riff-16khz-16kbps-mono-siren": SpeechSDK.SpeechSynthesisOutputFormat.Riff16Khz16KbpsMonoSiren,
8
8
  "audio-16khz-16kbps-mono-siren": SpeechSDK.SpeechSynthesisOutputFormat.Audio16Khz16KbpsMonoSiren,
@@ -42,10 +42,21 @@ const OUTPUT_FORMATS: Record<string, SpeechSDK.SpeechSynthesisOutputFormat> = {
42
42
  "riff-44100hz-16bit-mono-pcm": SpeechSDK.SpeechSynthesisOutputFormat.Riff44100Hz16BitMonoPcm,
43
43
  "amr-wb-16000hz": SpeechSDK.SpeechSynthesisOutputFormat.AmrWb16000Hz,
44
44
  "g722-16khz-64kbps": SpeechSDK.SpeechSynthesisOutputFormat.G72216Khz64Kbps,
45
- };
45
+ } satisfies Record<string, SpeechSDK.SpeechSynthesisOutputFormat>;
46
+
47
+ export type AzureTtsOutputFormat = keyof typeof OUTPUT_FORMATS;
48
+
49
+ export function resolveMimeType(outputFormat: string): string {
50
+ if (/(?:wav|wave|riff)/i.test(outputFormat)) return "audio/wav";
51
+ if (/(?:mp3|mpeg)/i.test(outputFormat)) return "audio/mpeg";
52
+ if (/ogg/i.test(outputFormat)) return "audio/ogg";
53
+ if (/webm/i.test(outputFormat)) return "audio/webm";
54
+ if (/raw/i.test(outputFormat)) return "audio/L16";
55
+ return "application/octet-stream";
56
+ }
46
57
 
47
58
  export function resolveOutputFormat(outputFormat: string): SpeechSDK.SpeechSynthesisOutputFormat {
48
- const resolvedFormat = OUTPUT_FORMATS[outputFormat];
59
+ const resolvedFormat = (OUTPUT_FORMATS as Record<string, SpeechSDK.SpeechSynthesisOutputFormat>)[outputFormat];
49
60
  if (resolvedFormat === undefined) {
50
61
  throw new Error(`Unsupported Azure Speech output format: ${outputFormat}`);
51
62
  }
package/src/safe.ts CHANGED
@@ -1,17 +1,37 @@
1
- import { validateAzureSsml, type AzureValidationOptions, type SsmlDiagnostic } from "@ssml-builder-js/ssml-core";
2
- import { AzureTtsError, createSpeechSdkError } from "./errors.ts";
1
+ import {
2
+ createAzureUrlValidatorRunner,
3
+ validateAzureSsml,
4
+ type AzureUrlValidator,
5
+ type AzureValidationOptions,
6
+ type SsmlDiagnostic,
7
+ } from "@ssml-builder-js/ssml-core";
8
+ import {
9
+ type AzureTtsError,
10
+ type AzureTtsSynthesisError,
11
+ type SynthesisErrorKind,
12
+ toSynthesisError,
13
+ } from "./errors.ts";
3
14
  import type { AzureTtsClient } from "./client.ts";
4
15
  import { mergeSynthesisResults } from "./synthesis.ts";
5
- import type { SsmlSynthesisChunk, SsmlSynthesisResult, SynthesisProgressEvent } from "./types.ts";
16
+ import type {
17
+ SsmlSynthesisChunk,
18
+ SsmlSynthesisResult,
19
+ SynthesisProgressEvent,
20
+ SynthesizeChunksOptions,
21
+ RetryOptions,
22
+ } from "./types.ts";
23
+ import type { AzureTtsOutputFormat } from "./outputFormats.ts";
6
24
 
7
25
  export interface SsmlValidationError {
8
- readonly kind: "validation";
26
+ readonly kind: "validation-error";
9
27
  readonly message: string;
10
28
  readonly diagnostics: readonly SsmlDiagnostic[];
11
29
  }
12
30
 
31
+ export type SsmlSynthesisError = SsmlValidationError | AzureTtsSynthesisError;
32
+
13
33
  export class ChunkValidationError extends Error {
14
- readonly kind = "chunk-validation" as const;
34
+ readonly kind = "validation-error" as const;
15
35
  readonly chunkIndex: number;
16
36
  readonly diagnostics: readonly SsmlDiagnostic[];
17
37
 
@@ -25,15 +45,26 @@ export class ChunkValidationError extends Error {
25
45
 
26
46
  export type Result<T, E> =
27
47
  | { readonly ok: true; readonly success: true; readonly status: "success"; readonly value: T }
28
- | {
29
- readonly ok: false;
30
- readonly success: false;
31
- readonly status: "validation-error" | "azure-api-error";
32
- readonly error: E;
33
- };
48
+ | (E extends { readonly kind: infer Kind extends SynthesisErrorKind }
49
+ ? {
50
+ readonly ok: false;
51
+ readonly success: false;
52
+ readonly status: Kind;
53
+ readonly error: E;
54
+ }
55
+ : {
56
+ readonly ok: false;
57
+ readonly success: false;
58
+ readonly status: SynthesisErrorKind;
59
+ readonly error: E;
60
+ });
34
61
 
35
62
  export type SynthesisResult<T, E> = Result<T, E>;
36
63
 
64
+ function failure<E extends { readonly kind: SynthesisErrorKind }>(error: E): Result<never, E> {
65
+ return { ok: false, success: false, status: error.kind, error } as Result<never, E>;
66
+ }
67
+
37
68
  export type Success<T> = Extract<Result<T, never>, { readonly ok: true }>;
38
69
  export type ValidationErrorResult = Extract<
39
70
  Result<never, SsmlValidationError>,
@@ -44,59 +75,147 @@ export type AzureApiErrorResult = Extract<Result<never, AzureTtsError>, { readon
44
75
  export type SsmlSynthesisSafeResult =
45
76
  | Result<SsmlSynthesisResult, never>
46
77
  | Result<never, SsmlValidationError>
47
- | Result<never, AzureTtsError>;
78
+ | Result<never, SsmlSynthesisError>;
48
79
 
49
80
  export interface SynthesizeSsmlSafeOptions extends AzureValidationOptions {
50
81
  /** Optional nested form for callers that want to keep validation settings grouped. */
51
82
  validation?: AzureValidationOptions;
83
+ signal?: AbortSignal;
52
84
  }
53
85
 
54
86
  export interface SynthesizeSsmlChunksSafeOptions extends AzureValidationOptions {
55
87
  validation?: AzureValidationOptions;
56
88
  outputFormat?: string;
89
+ signal?: AbortSignal;
90
+ timeoutMs?: number;
91
+ sourceNodePath?: string[];
57
92
  onProgress?: (event: SynthesisProgressEvent) => void;
93
+ concurrency?: number;
94
+ retryOptions?: RetryOptions;
58
95
  }
59
96
 
60
97
  export type SsmlSynthesisChunksSafeResult =
61
98
  | Result<SsmlSynthesisResult, never>
62
99
  | Result<never, ChunkValidationError>
63
- | Result<never, AzureTtsError>;
100
+ | Result<never, SsmlSynthesisError | ChunkValidationError>;
64
101
 
65
102
  interface SynthesisClient {
66
- synthesizeSsml(ssml: string): Promise<SsmlSynthesisResult>;
103
+ synthesizeSsml(ssml: string, options?: Partial<SynthesizeChunksOptions>): Promise<SsmlSynthesisResult>;
67
104
  synthesizeChunks?(
68
105
  chunks: readonly (SsmlSynthesisChunk | string)[],
69
- options?: { onProgress?: (event: SynthesisProgressEvent) => void },
106
+ options?: SynthesizeChunksOptions,
70
107
  ): Promise<SsmlSynthesisResult>;
71
108
  }
72
109
 
110
+ function isRetryable(error: unknown): boolean {
111
+ if (error instanceof Error && /cancel|abort|tim(?:e|ed) ?out/i.test(error.message)) return false;
112
+ const status =
113
+ error && typeof error === "object" && "status" in error ? (error as { status?: unknown }).status : undefined;
114
+ if (typeof status === "number" && status !== 0) return status === 429 || (status >= 500 && status < 600);
115
+ const message = error instanceof Error ? error.message : String(error);
116
+ if (/\b4\d{2}\b/.test(message)) return false;
117
+ return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
118
+ }
119
+
120
+ function delayForRetry(options: RetryOptions, attempt: number): number {
121
+ const maxDelay = Math.max(0, options.maxDelayMs);
122
+ const base = Math.min(maxDelay, Math.max(0, options.initialDelayMs) * 2 ** Math.max(0, attempt - 1));
123
+ return Math.floor(Math.random() * (base + 1));
124
+ }
125
+
126
+ function resolveConcurrency(value: number | undefined, total: number): number {
127
+ if (value === undefined) return 1;
128
+ if (value === Infinity) return Math.max(1, total);
129
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
130
+ }
131
+
132
+ async function retryableSynthesis(
133
+ synthesize: () => Promise<SsmlSynthesisResult>,
134
+ options: RetryOptions | undefined,
135
+ signal: AbortSignal | undefined,
136
+ onRetry: (attempt: number, delayMs: number) => void,
137
+ ): Promise<SsmlSynthesisResult> {
138
+ const retry = options
139
+ ? {
140
+ maxRetries: Math.max(0, Math.floor(options.maxRetries)),
141
+ initialDelayMs: options.initialDelayMs,
142
+ maxDelayMs: options.maxDelayMs,
143
+ }
144
+ : undefined;
145
+ let attempt = 0;
146
+ while (true) {
147
+ if (signal?.aborted) throw new Error("Speech synthesis was cancelled.");
148
+ try {
149
+ return await synthesize();
150
+ } catch (error) {
151
+ if (!retry || attempt >= retry.maxRetries || !isRetryable(error)) throw error;
152
+ attempt += 1;
153
+ const delayMs = delayForRetry(retry, attempt);
154
+ onRetry(attempt, delayMs);
155
+ if (delayMs > 0)
156
+ await new Promise<void>((resolve, reject) => {
157
+ const timer = setTimeout(() => {
158
+ signal?.removeEventListener("abort", abort);
159
+ resolve();
160
+ }, delayMs);
161
+ const abort = () => {
162
+ clearTimeout(timer);
163
+ signal?.removeEventListener("abort", abort);
164
+ reject(new Error("Speech synthesis was cancelled."));
165
+ };
166
+ signal?.addEventListener("abort", abort, { once: true });
167
+ });
168
+ }
169
+ }
170
+ }
171
+
172
+ function sharedValidationOptions(options: AzureValidationOptions, signal?: AbortSignal): AzureValidationOptions {
173
+ const validator = options.urlValidator ?? options.customUrlValidator;
174
+ if (!validator) return signal ? withValidationSignal(options, signal) : options;
175
+ const runner = createAzureUrlValidatorRunner(validator as AzureUrlValidator, {
176
+ ...(options.urlValidation ?? {}),
177
+ ...(options.urlValidatorConcurrency !== undefined ? { concurrency: options.urlValidatorConcurrency } : {}),
178
+ ...(options.urlValidatorTimeoutMs !== undefined ? { timeoutMs: options.urlValidatorTimeoutMs } : {}),
179
+ ...(signal ? { signal } : {}),
180
+ ...(options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}),
181
+ });
182
+ return {
183
+ ...withValidationSignal(options, signal),
184
+ urlValidatorRunner: runner,
185
+ };
186
+ }
187
+
73
188
  /** Validates SSML before invoking Azure and converts validation/API failures to one result shape. */
74
189
  export async function synthesizeSsmlSafe(
75
190
  client: Pick<AzureTtsClient, "synthesizeSsml"> | SynthesisClient,
76
191
  ssml: string,
77
192
  options: SynthesizeSsmlSafeOptions = {},
78
193
  ): Promise<SsmlSynthesisSafeResult> {
79
- const validationOptions = options.validation ?? options;
194
+ const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
80
195
  const diagnostics = await Promise.resolve(validateAzureSsml(ssml, validationOptions));
196
+ if (options.signal?.aborted) {
197
+ const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
198
+ return failure(error);
199
+ }
81
200
  const errors = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
82
201
  if (errors.length > 0) {
83
- return {
84
- ok: false,
85
- success: false,
86
- status: "validation-error",
87
- error: {
88
- kind: "validation",
89
- message: "SSML validation failed; the Azure Speech API was not called.",
90
- diagnostics: errors,
91
- },
92
- };
202
+ return failure({
203
+ kind: "validation-error",
204
+ message: "SSML validation failed; the Azure Speech API was not called.",
205
+ diagnostics: errors,
206
+ });
93
207
  }
94
208
 
95
209
  try {
96
- return { ok: true, success: true, status: "success", value: await client.synthesizeSsml(ssml) };
210
+ return {
211
+ ok: true,
212
+ success: true,
213
+ status: "success",
214
+ value: await client.synthesizeSsml(ssml, { signal: options.signal }),
215
+ };
97
216
  } catch (error) {
98
- const azureError = error instanceof AzureTtsError ? error : createSpeechSdkError(error);
99
- return { ok: false, success: false, status: "azure-api-error", error: azureError };
217
+ const synthesisError = toSynthesisError(error);
218
+ return failure(synthesisError);
100
219
  }
101
220
  }
102
221
 
@@ -106,7 +225,11 @@ export async function synthesizeSsmlChunksSafe(
106
225
  chunks: readonly (SsmlSynthesisChunk | string)[],
107
226
  options: SynthesizeSsmlChunksSafeOptions = {},
108
227
  ): Promise<SsmlSynthesisChunksSafeResult> {
109
- const validationOptions = options.validation ?? options;
228
+ const validationOptions = sharedValidationOptions(options.validation ?? options, options.signal);
229
+ if (options.signal?.aborted) {
230
+ const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
231
+ return failure(error);
232
+ }
110
233
  const pending = (index: number, status: SynthesisProgressEvent["status"], error?: unknown): void => {
111
234
  options.onProgress?.({
112
235
  currentChunk: status === "success" ? index + 1 : index,
@@ -124,77 +247,203 @@ export async function synthesizeSsmlChunksSafe(
124
247
  pending(index, "pending");
125
248
  });
126
249
  const validations = await Promise.all(
127
- chunks.map(async (chunk) => {
250
+ chunks.map(async (chunk, index) => {
128
251
  const ssml = typeof chunk === "string" ? chunk : chunk.ssml;
129
- const diagnostics = await Promise.resolve(validateAzureSsml(ssml, validationOptions));
252
+ const sourceNodePath =
253
+ typeof chunk === "string" ? options.sourceNodePath : (chunk.sourceNodePath ?? options.sourceNodePath);
254
+ const diagnostics = await Promise.resolve(
255
+ validateAzureSsml(ssml, {
256
+ ...validationOptions,
257
+ ...(sourceNodePath ? { sourceNodePath } : {}),
258
+ chunkIndex: index,
259
+ }),
260
+ );
130
261
  return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
131
262
  }),
132
263
  );
133
264
  const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
265
+ if (options.signal?.aborted) {
266
+ const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
267
+ return failure(error);
268
+ }
134
269
  if (firstInvalidIndex >= 0) {
135
270
  const error = new ChunkValidationError(firstInvalidIndex, validations[firstInvalidIndex] ?? []);
136
271
  pending(firstInvalidIndex, "failed", error);
137
- return { ok: false, success: false, status: "validation-error", error };
272
+ return failure(error);
138
273
  }
139
274
 
140
275
  try {
141
276
  if (client.synthesizeChunks) {
142
- const value = await client.synthesizeChunks(chunks, { onProgress: options.onProgress });
277
+ const normalizedChunks = chunks.map((chunk) => {
278
+ if (typeof chunk === "string" || chunk.sourceNodePath || !options.sourceNodePath) return chunk;
279
+ return { ...chunk, sourceNodePath: [...options.sourceNodePath] };
280
+ });
281
+ const value = await client.synthesizeChunks(normalizedChunks, {
282
+ onProgress: options.onProgress,
283
+ outputFormat: options.outputFormat,
284
+ signal: options.signal,
285
+ timeoutMs: options.timeoutMs,
286
+ sourceNodePath: options.sourceNodePath,
287
+ concurrency: options.concurrency,
288
+ retryOptions: options.retryOptions,
289
+ });
143
290
  return { ok: true, success: true, status: "success", value };
144
291
  }
145
- const results: SsmlSynthesisResult[] = [];
146
- for (const [index, chunk] of chunks.entries()) {
147
- const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
148
- const sourceNodePath = input.sourceNodePath;
149
- pending(index, "synthesizing");
150
- const startedAt = Date.now();
151
- try {
152
- const result = await client.synthesizeSsml(input.ssml);
153
- results.push({
154
- ...result,
155
- ...(input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {}),
156
- ...(sourceNodePath
157
- ? {
158
- boundaries: result.boundaries?.map((event) => ({
159
- ...event,
160
- sourceNodePath: [...sourceNodePath],
161
- })),
162
- visemes: result.visemes?.map((event) => ({ ...event, sourceNodePath: [...sourceNodePath] })),
163
- bookmarks: result.bookmarks?.map((event) => ({ ...event, sourceNodePath: [...sourceNodePath] })),
164
- }
165
- : {}),
166
- });
167
- options.onProgress?.({
168
- currentChunk: index + 1,
169
- totalChunks: chunks.length,
170
- percent: chunks.length === 0 ? 100 : Math.round(((index + 1) / chunks.length) * 100),
171
- chunkIndex: index,
172
- originalTextRange: input.originalTextRange,
173
- status: "success",
174
- durationMs: Date.now() - startedAt,
175
- });
176
- } catch (error) {
177
- options.onProgress?.({
178
- currentChunk: index,
179
- totalChunks: chunks.length,
180
- percent: chunks.length === 0 ? 100 : Math.round((index / chunks.length) * 100),
181
- chunkIndex: index,
182
- originalTextRange: input.originalTextRange,
183
- status: "failed",
184
- durationMs: Date.now() - startedAt,
185
- error,
186
- });
187
- throw error;
292
+ const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
293
+ let completed = 0;
294
+ let nextIndex = 0;
295
+ const concurrency = resolveConcurrency(options.concurrency, chunks.length);
296
+ const worker = async (): Promise<void> => {
297
+ while (true) {
298
+ const index = nextIndex++;
299
+ if (index >= chunks.length) return;
300
+ const chunk = chunks[index];
301
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
302
+ const sourceNodePath = input.sourceNodePath;
303
+ const originalTextRange = input.originalTextRange;
304
+ pending(index, "synthesizing");
305
+ const startedAt = Date.now();
306
+ try {
307
+ const result = await retryableSynthesis(
308
+ () =>
309
+ client.synthesizeSsml(input.ssml, {
310
+ outputFormat: options.outputFormat,
311
+ signal: options.signal,
312
+ timeoutMs: options.timeoutMs,
313
+ sourceNodePath: input.sourceNodePath ?? options.sourceNodePath,
314
+ }),
315
+ options.retryOptions,
316
+ options.signal,
317
+ (retryAttempt, nextRetryDelayMs) =>
318
+ options.onProgress?.({
319
+ currentChunk: completed,
320
+ totalChunks: chunks.length,
321
+ percent: chunks.length === 0 ? 100 : Math.round((completed / chunks.length) * 100),
322
+ chunkIndex: index,
323
+ originalTextRange: input.originalTextRange,
324
+ status: "synthesizing",
325
+ durationMs: Date.now() - startedAt,
326
+ retryAttempt,
327
+ nextRetryDelayMs,
328
+ isRetrying: true,
329
+ }),
330
+ );
331
+ results[index] = {
332
+ ...result,
333
+ ...(input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {}),
334
+ ...(sourceNodePath
335
+ ? {
336
+ boundaries: result.boundaries?.map((event) => ({
337
+ ...event,
338
+ sourceNodePath: [...sourceNodePath],
339
+ ...(event.originalTextRange
340
+ ? { originalTextRange: { ...event.originalTextRange } }
341
+ : input.originalTextRange
342
+ ? { originalTextRange: { ...input.originalTextRange } }
343
+ : {}),
344
+ })),
345
+ visemes: result.visemes?.map((event) => ({
346
+ ...event,
347
+ sourceNodePath: [...sourceNodePath],
348
+ ...(event.originalTextRange
349
+ ? { originalTextRange: { ...event.originalTextRange } }
350
+ : input.originalTextRange
351
+ ? { originalTextRange: { ...input.originalTextRange } }
352
+ : {}),
353
+ })),
354
+ bookmarks: result.bookmarks?.map((event) => ({
355
+ ...event,
356
+ sourceNodePath: [...sourceNodePath],
357
+ ...(event.originalTextRange
358
+ ? { originalTextRange: { ...event.originalTextRange } }
359
+ : input.originalTextRange
360
+ ? { originalTextRange: { ...input.originalTextRange } }
361
+ : {}),
362
+ })),
363
+ }
364
+ : {}),
365
+ ...(originalTextRange
366
+ ? {
367
+ boundaries: result.boundaries?.map((event) => ({
368
+ ...event,
369
+ originalTextRange: event.originalTextRange
370
+ ? { ...event.originalTextRange }
371
+ : { ...originalTextRange },
372
+ })),
373
+ wordBoundary: result.wordBoundary?.map((event) => ({
374
+ ...event,
375
+ originalTextRange: event.originalTextRange
376
+ ? { ...event.originalTextRange }
377
+ : { ...originalTextRange },
378
+ })),
379
+ wordBoundaries: result.wordBoundaries?.map((event) => ({
380
+ ...event,
381
+ originalTextRange: event.originalTextRange
382
+ ? { ...event.originalTextRange }
383
+ : { ...originalTextRange },
384
+ })),
385
+ visemes: result.visemes?.map((event) => ({
386
+ ...event,
387
+ originalTextRange: event.originalTextRange
388
+ ? { ...event.originalTextRange }
389
+ : { ...originalTextRange },
390
+ })),
391
+ bookmarks: result.bookmarks?.map((event) => ({
392
+ ...event,
393
+ originalTextRange: event.originalTextRange
394
+ ? { ...event.originalTextRange }
395
+ : { ...originalTextRange },
396
+ })),
397
+ }
398
+ : {}),
399
+ };
400
+ completed += 1;
401
+ options.onProgress?.({
402
+ currentChunk: completed,
403
+ totalChunks: chunks.length,
404
+ percent: chunks.length === 0 ? 100 : Math.round((completed / chunks.length) * 100),
405
+ chunkIndex: index,
406
+ originalTextRange: input.originalTextRange,
407
+ status: "success",
408
+ durationMs: Date.now() - startedAt,
409
+ });
410
+ } catch (error) {
411
+ options.onProgress?.({
412
+ currentChunk: completed,
413
+ totalChunks: chunks.length,
414
+ percent: chunks.length === 0 ? 100 : Math.round((index / chunks.length) * 100),
415
+ chunkIndex: index,
416
+ originalTextRange: input.originalTextRange,
417
+ status: "failed",
418
+ durationMs: Date.now() - startedAt,
419
+ error,
420
+ });
421
+ throw error;
422
+ }
188
423
  }
189
- }
424
+ };
425
+ await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
426
+ const orderedResults = results.filter((result): result is SsmlSynthesisResult => result !== undefined);
190
427
  return {
191
428
  ok: true,
192
429
  success: true,
193
430
  status: "success",
194
- value: mergeSynthesisResults(results, options.outputFormat),
431
+ value: mergeSynthesisResults(orderedResults, {
432
+ format: (options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3") as AzureTtsOutputFormat,
433
+ signal: options.signal,
434
+ }),
195
435
  };
196
436
  } catch (error) {
197
- const azureError = error instanceof AzureTtsError ? error : createSpeechSdkError(error);
198
- return { ok: false, success: false, status: "azure-api-error", error: azureError };
437
+ const synthesisError = toSynthesisError(error);
438
+ return failure(synthesisError);
199
439
  }
200
440
  }
441
+
442
+ function withValidationSignal(options: AzureValidationOptions, signal?: AbortSignal): AzureValidationOptions {
443
+ if (!signal) return options;
444
+ return {
445
+ ...options,
446
+ urlValidatorSignal: signal,
447
+ urlValidation: { ...(options.urlValidation ?? {}), signal },
448
+ };
449
+ }