@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/src/synthesis.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
SynthesisTimeoutError,
|
|
9
9
|
toSynthesisError,
|
|
10
10
|
UnsupportedMergeFormatError,
|
|
11
|
+
getRetryAfterDelayMs,
|
|
11
12
|
} from "./errors.ts";
|
|
12
13
|
import { DEFAULT_OUTPUT_FORMAT, resolveMimeType, type AzureTtsOutputFormat } from "./outputFormats.ts";
|
|
13
14
|
import { createSpeechConfig } from "./speechConfig.ts";
|
|
@@ -19,6 +20,8 @@ import type {
|
|
|
19
20
|
SynthesisProgressEvent,
|
|
20
21
|
TtsConfig,
|
|
21
22
|
RetryOptions,
|
|
23
|
+
CustomAudioMerger,
|
|
24
|
+
PostMergeValidator,
|
|
22
25
|
} from "./types.ts";
|
|
23
26
|
|
|
24
27
|
export type MergeAudioFormat = "wav" | "mp3" | "raw";
|
|
@@ -31,15 +34,9 @@ export interface MergeAudioOptions {
|
|
|
31
34
|
|
|
32
35
|
export type InputAudioSpecs = AudioSpecification[];
|
|
33
36
|
|
|
34
|
-
export interface CustomMergerContext {
|
|
35
|
-
format: string;
|
|
36
|
-
outputMimeType: string;
|
|
37
|
-
inputSpecs: InputAudioSpecs;
|
|
38
|
-
signal: AbortSignal;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
37
|
export interface MergeSynthesisOptions extends MergeAudioOptions {
|
|
42
|
-
customMerger?:
|
|
38
|
+
customMerger?: CustomAudioMerger;
|
|
39
|
+
postMergeValidator?: PostMergeValidator;
|
|
43
40
|
}
|
|
44
41
|
|
|
45
42
|
type AsyncMergeSynthesisOptions = MergeSynthesisOptions & {
|
|
@@ -124,6 +121,18 @@ function formatAudioSpecification(format: string): AudioSpecification {
|
|
|
124
121
|
: /pcm|mulaw|alaw|siren/i.test(format)
|
|
125
122
|
? "pcm"
|
|
126
123
|
: "unknown";
|
|
124
|
+
const bitDepthMatch = /(\d+)bit/i.exec(format);
|
|
125
|
+
const container = /(?:wav|wave|riff)/i.test(format)
|
|
126
|
+
? "riff-wave"
|
|
127
|
+
: /mp3|mpeg/i.test(format)
|
|
128
|
+
? "mp3-raw"
|
|
129
|
+
: /ogg/i.test(format)
|
|
130
|
+
? "ogg"
|
|
131
|
+
: /webm/i.test(format)
|
|
132
|
+
? "webm"
|
|
133
|
+
: /raw/i.test(format)
|
|
134
|
+
? "raw"
|
|
135
|
+
: undefined;
|
|
127
136
|
return {
|
|
128
137
|
format,
|
|
129
138
|
mimeType: resolveMimeType(format),
|
|
@@ -131,6 +140,9 @@ function formatAudioSpecification(format: string): AudioSpecification {
|
|
|
131
140
|
sampleRate,
|
|
132
141
|
channels,
|
|
133
142
|
...(bitrate ? { bitrate } : {}),
|
|
143
|
+
...(bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {}),
|
|
144
|
+
...(container ? { container } : {}),
|
|
145
|
+
isVbr: /vbr/i.test(format),
|
|
134
146
|
isCompressed: codec === "mp3" || codec === "opus" || codec === "silk",
|
|
135
147
|
};
|
|
136
148
|
}
|
|
@@ -167,6 +179,8 @@ function parseMp3Specification(buffer: ArrayBuffer, format: string): AudioSpecif
|
|
|
167
179
|
sampleRate,
|
|
168
180
|
channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
|
|
169
181
|
bitrate: bitrateKbps * 1000,
|
|
182
|
+
container: "mp3-raw",
|
|
183
|
+
isVbr: false,
|
|
170
184
|
isCompressed: true,
|
|
171
185
|
};
|
|
172
186
|
}
|
|
@@ -190,6 +204,9 @@ export function inspectAudioSpecification(buffer: ArrayBuffer, format: string):
|
|
|
190
204
|
sampleRate,
|
|
191
205
|
channels,
|
|
192
206
|
...(sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {}),
|
|
207
|
+
bitDepth: bitsPerSample,
|
|
208
|
+
container: "riff-wave",
|
|
209
|
+
isVbr: false,
|
|
193
210
|
isCompressed: formatCode !== 1,
|
|
194
211
|
};
|
|
195
212
|
}
|
|
@@ -204,7 +221,10 @@ function validateAudioSpecifications(specs: readonly AudioSpecification[]): void
|
|
|
204
221
|
(spec) =>
|
|
205
222
|
spec.sampleRate !== first.sampleRate ||
|
|
206
223
|
spec.channels !== first.channels ||
|
|
207
|
-
(first.bitrate !== undefined && spec.bitrate !== undefined && spec.bitrate !== first.bitrate)
|
|
224
|
+
(first.bitrate !== undefined && spec.bitrate !== undefined && spec.bitrate !== first.bitrate) ||
|
|
225
|
+
(first.bitDepth !== undefined && spec.bitDepth !== undefined && spec.bitDepth !== first.bitDepth) ||
|
|
226
|
+
(first.container !== undefined && spec.container !== undefined && spec.container !== first.container) ||
|
|
227
|
+
(first.isVbr !== undefined && spec.isVbr !== undefined && spec.isVbr !== first.isVbr),
|
|
208
228
|
);
|
|
209
229
|
if (mismatch)
|
|
210
230
|
throw new AudioFormatMismatchError(
|
|
@@ -448,9 +468,7 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
448
468
|
};
|
|
449
469
|
}
|
|
450
470
|
if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) {
|
|
451
|
-
|
|
452
|
-
Object.defineProperty(unmapped, "mappingStatus", { value: "unmapped", enumerable: false });
|
|
453
|
-
return unmapped;
|
|
471
|
+
return { mappingStatus: "unmapped" };
|
|
454
472
|
}
|
|
455
473
|
const value = text ?? "";
|
|
456
474
|
let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? (offsetHint as number) : -1;
|
|
@@ -546,8 +564,13 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
546
564
|
...(config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {}),
|
|
547
565
|
...(requestId ? { requestId } : {}),
|
|
548
566
|
} as T;
|
|
549
|
-
if (event.mappingStatus === "unmapped")
|
|
567
|
+
if (event.mappingStatus === "unmapped") {
|
|
550
568
|
Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
|
|
569
|
+
Object.defineProperty(mapped, "toJSON", {
|
|
570
|
+
value: () => ({ ...mapped, mappingStatus: "unmapped" }),
|
|
571
|
+
enumerable: false,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
551
574
|
return mapped;
|
|
552
575
|
};
|
|
553
576
|
const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
|
|
@@ -573,10 +596,11 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
573
596
|
abortHandler = () => rejectWithError(new SynthesisCancelledError());
|
|
574
597
|
config.signal.addEventListener("abort", abortHandler, { once: true });
|
|
575
598
|
}
|
|
576
|
-
|
|
599
|
+
const timeoutMs = config.timeouts?.perChunkMs ?? config.timeoutMs;
|
|
600
|
+
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
577
601
|
timeout = setTimeout(
|
|
578
|
-
() => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${
|
|
579
|
-
|
|
602
|
+
() => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
|
|
603
|
+
timeoutMs,
|
|
580
604
|
);
|
|
581
605
|
}
|
|
582
606
|
synthesizer.speakSsmlAsync(ssml, cb, rejectWithError);
|
|
@@ -597,7 +621,9 @@ function isRetryableSynthesisError(error: unknown): boolean {
|
|
|
597
621
|
return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
|
|
598
622
|
}
|
|
599
623
|
|
|
600
|
-
function retryDelay(options: RetryOptions, retryAttempt: number): number {
|
|
624
|
+
function retryDelay(options: RetryOptions, retryAttempt: number, error?: unknown): number {
|
|
625
|
+
const retryAfterMs = getRetryAfterDelayMs(error);
|
|
626
|
+
if (retryAfterMs !== undefined) return retryAfterMs;
|
|
601
627
|
const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
|
|
602
628
|
return Math.floor(Math.random() * (base + 1));
|
|
603
629
|
}
|
|
@@ -639,6 +665,7 @@ async function synthesizeWithRetry(
|
|
|
639
665
|
maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
|
|
640
666
|
initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
|
|
641
667
|
maxDelayMs: Math.max(0, retryOptions.maxDelayMs),
|
|
668
|
+
shouldRetry: retryOptions.shouldRetry,
|
|
642
669
|
}
|
|
643
670
|
: undefined;
|
|
644
671
|
let attempt = 0;
|
|
@@ -647,15 +674,69 @@ async function synthesizeWithRetry(
|
|
|
647
674
|
try {
|
|
648
675
|
return await synthesizeSsml(ssml, config);
|
|
649
676
|
} catch (error) {
|
|
650
|
-
if (
|
|
677
|
+
if (
|
|
678
|
+
!options ||
|
|
679
|
+
attempt >= options.maxRetries ||
|
|
680
|
+
!(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error))
|
|
681
|
+
)
|
|
682
|
+
throw error;
|
|
651
683
|
attempt += 1;
|
|
652
|
-
const delayMs = retryDelay(options, attempt);
|
|
684
|
+
const delayMs = retryDelay(options, attempt, error);
|
|
653
685
|
onRetry(attempt, delayMs);
|
|
654
686
|
await waitForRetry(delayMs, config.signal);
|
|
655
687
|
}
|
|
656
688
|
}
|
|
657
689
|
}
|
|
658
690
|
|
|
691
|
+
interface AbortScope {
|
|
692
|
+
signal: AbortSignal;
|
|
693
|
+
timedOut: () => boolean;
|
|
694
|
+
dispose: () => void;
|
|
695
|
+
abort: () => void;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function createAbortScope(parent: AbortSignal | undefined, timeoutMs: number | undefined): AbortScope {
|
|
699
|
+
const controller = new AbortController();
|
|
700
|
+
let didTimeout = false;
|
|
701
|
+
const onAbort = () => controller.abort();
|
|
702
|
+
if (parent?.aborted) controller.abort();
|
|
703
|
+
parent?.addEventListener("abort", onAbort, { once: true });
|
|
704
|
+
const timer =
|
|
705
|
+
timeoutMs !== undefined && timeoutMs > 0
|
|
706
|
+
? setTimeout(() => {
|
|
707
|
+
didTimeout = true;
|
|
708
|
+
controller.abort();
|
|
709
|
+
}, timeoutMs)
|
|
710
|
+
: undefined;
|
|
711
|
+
return {
|
|
712
|
+
signal: controller.signal,
|
|
713
|
+
timedOut: () => didTimeout,
|
|
714
|
+
dispose: () => {
|
|
715
|
+
if (timer) clearTimeout(timer);
|
|
716
|
+
parent?.removeEventListener("abort", onAbort);
|
|
717
|
+
},
|
|
718
|
+
abort: () => controller.abort(),
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async function synthesizeChunkWithTimeout(
|
|
723
|
+
ssml: string,
|
|
724
|
+
config: TtsConfig,
|
|
725
|
+
retryOptions: RetryOptions | undefined,
|
|
726
|
+
timeoutMs: number | undefined,
|
|
727
|
+
onRetry: (retryAttempt: number, nextRetryDelayMs: number) => void,
|
|
728
|
+
): Promise<SsmlSynthesisResult> {
|
|
729
|
+
const scope = createAbortScope(config.signal, timeoutMs);
|
|
730
|
+
try {
|
|
731
|
+
return await synthesizeWithRetry(ssml, { ...config, signal: scope.signal }, retryOptions, onRetry);
|
|
732
|
+
} catch (error) {
|
|
733
|
+
if (scope.timedOut()) throw new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`);
|
|
734
|
+
throw error;
|
|
735
|
+
} finally {
|
|
736
|
+
scope.dispose();
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
659
740
|
/** Synthesizes chunks with bounded concurrency, retries transient failures, and merges in chunk order. */
|
|
660
741
|
export async function synthesizeSsmlChunks(
|
|
661
742
|
chunks: readonly (SsmlSynthesisChunk | string)[],
|
|
@@ -663,6 +744,16 @@ export async function synthesizeSsmlChunks(
|
|
|
663
744
|
): Promise<SsmlSynthesisResult> {
|
|
664
745
|
const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
|
|
665
746
|
const totalChunks = chunks.length;
|
|
747
|
+
const cachedChunks = new Map((config.resumeChunks ?? []).map((chunk) => [chunk.chunkIndex, chunk]));
|
|
748
|
+
for (const [index, cached] of cachedChunks) {
|
|
749
|
+
if (index >= 0 && index < totalChunks) results[index] = cached;
|
|
750
|
+
}
|
|
751
|
+
const requestedIndices = config.resumeChunkIndices
|
|
752
|
+
? new Set(config.resumeChunkIndices.filter((index) => index >= 0 && index < totalChunks))
|
|
753
|
+
: undefined;
|
|
754
|
+
const shouldSynthesize = (index: number): boolean =>
|
|
755
|
+
!cachedChunks.has(index) && (requestedIndices === undefined || requestedIndices.has(index));
|
|
756
|
+
const scope = createAbortScope(config.signal, config.timeouts?.totalJobMs);
|
|
666
757
|
const report = (event: SynthesisProgressEvent): void => config.onProgress?.(event);
|
|
667
758
|
for (const [index, chunk] of chunks.entries()) {
|
|
668
759
|
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
@@ -676,13 +767,17 @@ export async function synthesizeSsmlChunks(
|
|
|
676
767
|
durationMs: 0,
|
|
677
768
|
});
|
|
678
769
|
}
|
|
679
|
-
let completed =
|
|
770
|
+
let completed = [...results].filter((result) => result !== undefined).length;
|
|
680
771
|
let nextIndex = 0;
|
|
681
772
|
const concurrency = resolveConcurrency(config.concurrency, chunks.length);
|
|
773
|
+
let firstError: unknown;
|
|
774
|
+
const failedIndices = new Set<number>();
|
|
682
775
|
const worker = async (): Promise<void> => {
|
|
683
776
|
while (true) {
|
|
684
777
|
const index = nextIndex++;
|
|
685
778
|
if (index >= chunks.length) return;
|
|
779
|
+
if (!shouldSynthesize(index)) continue;
|
|
780
|
+
if (firstError && config.cancelOnFailure !== false) return;
|
|
686
781
|
const chunk = chunks[index];
|
|
687
782
|
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
688
783
|
report({
|
|
@@ -696,10 +791,11 @@ export async function synthesizeSsmlChunks(
|
|
|
696
791
|
});
|
|
697
792
|
const startedAt = Date.now();
|
|
698
793
|
try {
|
|
699
|
-
const result = await
|
|
794
|
+
const result = await synthesizeChunkWithTimeout(
|
|
700
795
|
input.ssml,
|
|
701
796
|
{
|
|
702
797
|
...config,
|
|
798
|
+
signal: scope.signal,
|
|
703
799
|
...(input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {}),
|
|
704
800
|
...((input.sourceNodePath ?? config.sourceNodePath)
|
|
705
801
|
? { sourceNodePath: [...(input.sourceNodePath ?? config.sourceNodePath ?? [])] }
|
|
@@ -710,6 +806,7 @@ export async function synthesizeSsmlChunks(
|
|
|
710
806
|
onProgress: undefined,
|
|
711
807
|
},
|
|
712
808
|
config.retryOptions,
|
|
809
|
+
config.timeouts?.chunkWithRetriesMs ?? config.timeouts?.perChunkMs ?? config.timeoutMs,
|
|
713
810
|
(retryAttempt, nextRetryDelayMs) =>
|
|
714
811
|
report({
|
|
715
812
|
currentChunk: completed,
|
|
@@ -736,6 +833,7 @@ export async function synthesizeSsmlChunks(
|
|
|
736
833
|
durationMs: Date.now() - startedAt,
|
|
737
834
|
});
|
|
738
835
|
} catch (error) {
|
|
836
|
+
failedIndices.add(index);
|
|
739
837
|
report({
|
|
740
838
|
currentChunk: completed,
|
|
741
839
|
totalChunks,
|
|
@@ -746,16 +844,38 @@ export async function synthesizeSsmlChunks(
|
|
|
746
844
|
durationMs: Date.now() - startedAt,
|
|
747
845
|
error,
|
|
748
846
|
});
|
|
749
|
-
|
|
847
|
+
firstError ??= scope.timedOut()
|
|
848
|
+
? new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeouts?.totalJobMs} ms.`)
|
|
849
|
+
: error;
|
|
850
|
+
if (config.cancelOnFailure !== false) scope.abort();
|
|
851
|
+
return;
|
|
750
852
|
}
|
|
751
853
|
}
|
|
752
854
|
};
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
855
|
+
try {
|
|
856
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
|
|
857
|
+
if (firstError) throw firstError;
|
|
858
|
+
const orderedResults = results.filter((result): result is SsmlSynthesisResult => result !== undefined);
|
|
859
|
+
return await mergeSynthesisResults(orderedResults, {
|
|
860
|
+
format: (config.outputFormat ?? DEFAULT_OUTPUT_FORMAT) as AzureTtsOutputFormat,
|
|
861
|
+
signal: scope.signal,
|
|
862
|
+
customMerger: config.customMerger,
|
|
863
|
+
outputMimeType: config.outputMimeType,
|
|
864
|
+
postMergeValidator: config.postMergeValidator,
|
|
865
|
+
});
|
|
866
|
+
} catch (error) {
|
|
867
|
+
const partial = {
|
|
868
|
+
synthesizedChunks: results.flatMap((result, chunkIndex) => (result ? [{ ...result, chunkIndex }] : [])),
|
|
869
|
+
completedChunks: results.flatMap((result, chunkIndex) => (result ? [{ ...result, chunkIndex }] : [])),
|
|
870
|
+
pendingChunkIndices: chunks.flatMap((_chunk, chunkIndex) => (results[chunkIndex] ? [] : [chunkIndex])),
|
|
871
|
+
failedChunkIndices: [...failedIndices],
|
|
872
|
+
totalChunks,
|
|
873
|
+
};
|
|
874
|
+
if (error && typeof error === "object") (error as { partialResult?: unknown }).partialResult = partial;
|
|
875
|
+
throw error;
|
|
876
|
+
} finally {
|
|
877
|
+
scope.dispose();
|
|
878
|
+
}
|
|
759
879
|
}
|
|
760
880
|
|
|
761
881
|
/** Concatenates audio buffers and shifts all synchronization events by prior chunk durations. */
|
|
@@ -845,6 +965,10 @@ export function mergeSynthesisResults(
|
|
|
845
965
|
results: readonly SsmlSynthesisResult[],
|
|
846
966
|
options: AsyncMergeSynthesisOptions,
|
|
847
967
|
): Promise<MergedSynthesisResult>;
|
|
968
|
+
export function mergeSynthesisResults(
|
|
969
|
+
results: readonly SsmlSynthesisResult[],
|
|
970
|
+
options: MergeSynthesisOptions,
|
|
971
|
+
): MergedSynthesisResult | Promise<MergedSynthesisResult>;
|
|
848
972
|
export function mergeSynthesisResults(
|
|
849
973
|
results: readonly SsmlSynthesisResult[],
|
|
850
974
|
options: MergeAudioOptions,
|
|
@@ -880,13 +1004,24 @@ export function mergeSynthesisResults(
|
|
|
880
1004
|
)
|
|
881
1005
|
throw new MergeError("The custom audio merger returned an invalid audio buffer.");
|
|
882
1006
|
if (signal.aborted) throw new SynthesisCancelledError();
|
|
883
|
-
|
|
1007
|
+
const result = createMergedResult(
|
|
884
1008
|
results,
|
|
885
1009
|
merged,
|
|
886
1010
|
format,
|
|
887
1011
|
inspectAudioSpecification(merged, format),
|
|
888
1012
|
resolvedOptions.outputMimeType,
|
|
889
1013
|
);
|
|
1014
|
+
return Promise.resolve(
|
|
1015
|
+
resolvedOptions.postMergeValidator?.(result, {
|
|
1016
|
+
format,
|
|
1017
|
+
outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
|
|
1018
|
+
inputSpecs,
|
|
1019
|
+
signal,
|
|
1020
|
+
}),
|
|
1021
|
+
).then((valid) => {
|
|
1022
|
+
if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
|
|
1023
|
+
return result;
|
|
1024
|
+
});
|
|
890
1025
|
})
|
|
891
1026
|
.catch((error: unknown) => {
|
|
892
1027
|
if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
|
|
@@ -895,13 +1030,28 @@ export function mergeSynthesisResults(
|
|
|
895
1030
|
});
|
|
896
1031
|
}
|
|
897
1032
|
try {
|
|
898
|
-
|
|
1033
|
+
const result = createMergedResult(
|
|
899
1034
|
results,
|
|
900
1035
|
mergeAudioBuffers(buffers, { format }),
|
|
901
1036
|
format,
|
|
902
1037
|
inputSpecs[0],
|
|
903
1038
|
resolvedOptions.outputMimeType,
|
|
904
1039
|
);
|
|
1040
|
+
if (resolvedOptions.postMergeValidator) {
|
|
1041
|
+
const validation = resolvedOptions.postMergeValidator(result, {
|
|
1042
|
+
format,
|
|
1043
|
+
outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
|
|
1044
|
+
inputSpecs,
|
|
1045
|
+
signal,
|
|
1046
|
+
});
|
|
1047
|
+
if (validation instanceof Promise)
|
|
1048
|
+
return validation.then((valid) => {
|
|
1049
|
+
if (valid === false) throw new MergeError("The post-merge validator rejected the merged audio.");
|
|
1050
|
+
return result;
|
|
1051
|
+
});
|
|
1052
|
+
if (validation === false) throw new MergeError("The post-merge validator rejected the merged audio.");
|
|
1053
|
+
}
|
|
1054
|
+
return result;
|
|
905
1055
|
} catch (error) {
|
|
906
1056
|
if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
|
|
907
1057
|
throw error;
|
package/src/types.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { AzureTtsOutputFormat } from "./outputFormats.ts";
|
|
|
4
4
|
export interface TtsConfig {
|
|
5
5
|
signal?: AbortSignal;
|
|
6
6
|
timeoutMs?: number;
|
|
7
|
+
timeouts?: SynthesisTimeouts;
|
|
7
8
|
endpoint?: string;
|
|
8
9
|
subscriptionKey: string;
|
|
9
10
|
region: string;
|
|
@@ -20,6 +21,12 @@ export interface TtsConfig {
|
|
|
20
21
|
sourceMarkers?: SsmlSourceMarker[];
|
|
21
22
|
concurrency?: number;
|
|
22
23
|
retryOptions?: RetryOptions;
|
|
24
|
+
cancelOnFailure?: boolean;
|
|
25
|
+
resumeChunks?: readonly SynthesizedChunk[];
|
|
26
|
+
resumeChunkIndices?: readonly number[];
|
|
27
|
+
customMerger?: CustomAudioMerger;
|
|
28
|
+
outputMimeType?: string;
|
|
29
|
+
postMergeValidator?: PostMergeValidator;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
export type MappingStatus = "exact" | "fallback" | "unmapped";
|
|
@@ -31,13 +38,24 @@ export interface AudioSpecification {
|
|
|
31
38
|
sampleRate: number;
|
|
32
39
|
channels: number;
|
|
33
40
|
bitrate?: number;
|
|
41
|
+
bitDepth?: number;
|
|
42
|
+
container?: string;
|
|
43
|
+
isVbr?: boolean;
|
|
34
44
|
isCompressed: boolean;
|
|
35
45
|
}
|
|
36
46
|
|
|
47
|
+
export interface SynthesisTimeouts {
|
|
48
|
+
urlValidationMs?: number;
|
|
49
|
+
perChunkMs?: number;
|
|
50
|
+
chunkWithRetriesMs?: number;
|
|
51
|
+
totalJobMs?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
37
54
|
export interface RetryOptions {
|
|
38
55
|
maxRetries: number;
|
|
39
56
|
initialDelayMs: number;
|
|
40
57
|
maxDelayMs: number;
|
|
58
|
+
shouldRetry?: (error: unknown, attempt: number) => boolean;
|
|
41
59
|
}
|
|
42
60
|
|
|
43
61
|
export type SynthesisChunkStatus = "pending" | "synthesizing" | "success" | "failed";
|
|
@@ -120,11 +138,50 @@ export interface SynthesizeChunksOptions {
|
|
|
120
138
|
outputFormat?: AzureTtsOutputFormat | string;
|
|
121
139
|
signal?: AbortSignal;
|
|
122
140
|
timeoutMs?: number;
|
|
141
|
+
timeouts?: SynthesisTimeouts;
|
|
123
142
|
sourceNodePath?: string[];
|
|
124
143
|
concurrency?: number;
|
|
125
144
|
retryOptions?: RetryOptions;
|
|
145
|
+
cancelOnFailure?: boolean;
|
|
146
|
+
resumeChunks?: readonly SynthesizedChunk[];
|
|
147
|
+
resumeChunkIndices?: readonly number[];
|
|
148
|
+
customMerger?: CustomAudioMerger;
|
|
149
|
+
outputMimeType?: string;
|
|
150
|
+
postMergeValidator?: PostMergeValidator;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface CustomMergerContext {
|
|
154
|
+
format: string;
|
|
155
|
+
outputMimeType: string;
|
|
156
|
+
inputSpecs: readonly AudioSpecification[];
|
|
157
|
+
signal: AbortSignal;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type CustomAudioMerger = (
|
|
161
|
+
buffers: ArrayBuffer[],
|
|
162
|
+
context: CustomMergerContext,
|
|
163
|
+
) => Promise<ArrayBuffer> | ArrayBuffer;
|
|
164
|
+
|
|
165
|
+
export type PostMergeValidator = (
|
|
166
|
+
result: MergedSynthesisResult,
|
|
167
|
+
context: CustomMergerContext,
|
|
168
|
+
) => boolean | undefined | Promise<boolean | undefined>;
|
|
169
|
+
|
|
170
|
+
export interface SynthesizedChunk extends SsmlSynthesisResult {
|
|
171
|
+
chunkIndex: number;
|
|
126
172
|
}
|
|
127
173
|
|
|
174
|
+
export interface PartialChunkSynthesisResult {
|
|
175
|
+
synthesizedChunks: readonly SynthesizedChunk[];
|
|
176
|
+
completedChunks: readonly SynthesizedChunk[];
|
|
177
|
+
pendingChunkIndices: readonly number[];
|
|
178
|
+
failedChunkIndices: readonly number[];
|
|
179
|
+
totalChunks: number;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Alias for applications that use the shorter result name. */
|
|
183
|
+
export type PartialSynthesisResult = PartialChunkSynthesisResult;
|
|
184
|
+
|
|
128
185
|
export interface SynthesisProgressEvent {
|
|
129
186
|
/** 1-based completed chunk count retained for backward compatibility. */
|
|
130
187
|
currentChunk: number;
|
|
@@ -150,6 +207,7 @@ export interface AzureTtsLogger {
|
|
|
150
207
|
export interface AzureTtsClientOptions {
|
|
151
208
|
signal?: AbortSignal;
|
|
152
209
|
timeoutMs?: number;
|
|
210
|
+
timeouts?: SynthesisTimeouts;
|
|
153
211
|
subscriptionKey: string;
|
|
154
212
|
region: string;
|
|
155
213
|
endpoint?: string;
|
package/src/voiceCatalog.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface FetchedAzureVoiceCatalogMetadata {
|
|
|
22
22
|
generatedAt: string;
|
|
23
23
|
apiVersion: string;
|
|
24
24
|
regions: readonly string[];
|
|
25
|
+
expiresAt?: string;
|
|
26
|
+
regionDiffs?: Readonly<Record<string, readonly string[]>>;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export interface AzureVoiceCatalog {
|
|
@@ -130,6 +132,8 @@ export async function fetchAzureVoiceCatalog(options: FetchAzureVoiceCatalogOpti
|
|
|
130
132
|
generatedAt: new Date().toISOString(),
|
|
131
133
|
apiVersion: AZURE_VOICE_API_VERSION,
|
|
132
134
|
regions,
|
|
135
|
+
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
136
|
+
regionDiffs: {},
|
|
133
137
|
},
|
|
134
138
|
};
|
|
135
139
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
AzureTtsError,
|
|
5
|
+
BatchChunkValidationError,
|
|
6
|
+
getRetryAfterDelayMs,
|
|
7
|
+
synthesizeSsmlChunksSafe,
|
|
8
|
+
} from "../src/index.ts";
|
|
9
|
+
|
|
10
|
+
const validSsml = (text: string) =>
|
|
11
|
+
`<speak version="1.0" xml:lang="en-US"><voice name="en-US-JennyNeural">${text}</voice></speak>`;
|
|
12
|
+
|
|
13
|
+
test("aggregates every invalid chunk and every diagnostic before synthesis", async () => {
|
|
14
|
+
let calls = 0;
|
|
15
|
+
const result = await synthesizeSsmlChunksSafe(
|
|
16
|
+
{
|
|
17
|
+
synthesizeSsml: async () => {
|
|
18
|
+
calls += 1;
|
|
19
|
+
return { audioData: new ArrayBuffer(0), durationMs: 0 };
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
["<speak>", "<speak>", validSsml("ok")],
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
assert.equal(result.ok, false);
|
|
26
|
+
assert.equal(calls, 0);
|
|
27
|
+
if (!result.ok) {
|
|
28
|
+
assert.ok(result.error instanceof BatchChunkValidationError);
|
|
29
|
+
assert.deepEqual(
|
|
30
|
+
result.error.chunkDiagnostics.map(({ chunkIndex }) => chunkIndex),
|
|
31
|
+
[0, 1],
|
|
32
|
+
);
|
|
33
|
+
assert.equal(result.error.totalErrorCount, 2);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("custom merger and post-merge validation are part of safe chunk synthesis", async () => {
|
|
38
|
+
let validated = false;
|
|
39
|
+
const result = await synthesizeSsmlChunksSafe(
|
|
40
|
+
{
|
|
41
|
+
synthesizeSsml: async (ssml) => ({
|
|
42
|
+
audioData: Uint8Array.of(ssml.includes("one") ? 1 : 2).buffer,
|
|
43
|
+
durationMs: 10,
|
|
44
|
+
}),
|
|
45
|
+
},
|
|
46
|
+
[validSsml("one"), validSsml("two")],
|
|
47
|
+
{
|
|
48
|
+
concurrency: 2,
|
|
49
|
+
outputMimeType: "audio/custom",
|
|
50
|
+
customMerger: (buffers) => {
|
|
51
|
+
const output = new Uint8Array(buffers.reduce((total, buffer) => total + buffer.byteLength, 0));
|
|
52
|
+
let offset = 0;
|
|
53
|
+
for (const buffer of buffers) {
|
|
54
|
+
output.set(new Uint8Array(buffer), offset);
|
|
55
|
+
offset += buffer.byteLength;
|
|
56
|
+
}
|
|
57
|
+
return output.buffer;
|
|
58
|
+
},
|
|
59
|
+
postMergeValidator: (merged) => {
|
|
60
|
+
validated = merged.mimeType === "audio/custom";
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
assert.equal(result.ok, true);
|
|
66
|
+
assert.equal(validated, true);
|
|
67
|
+
if (result.ok) assert.equal(result.value.mimeType, "audio/custom");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("cancels remaining work and resumes from the partial chunk cache", async () => {
|
|
71
|
+
let calls = 0;
|
|
72
|
+
let failFirstAttempt = true;
|
|
73
|
+
const client = {
|
|
74
|
+
synthesizeSsml: async (ssml: string, options?: { signal?: AbortSignal }) => {
|
|
75
|
+
calls += 1;
|
|
76
|
+
if (ssml.includes("fail") && failFirstAttempt) {
|
|
77
|
+
failFirstAttempt = false;
|
|
78
|
+
throw new AzureTtsError(503, "Unavailable", "", null);
|
|
79
|
+
}
|
|
80
|
+
await new Promise<void>((resolve, reject) => {
|
|
81
|
+
const timer = setTimeout(resolve, ssml.includes("one") ? 1 : 50);
|
|
82
|
+
options?.signal?.addEventListener(
|
|
83
|
+
"abort",
|
|
84
|
+
() => {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
reject(new Error("aborted"));
|
|
87
|
+
},
|
|
88
|
+
{ once: true },
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
return { audioData: Uint8Array.of(calls).buffer, durationMs: 10 };
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
const chunks = [validSsml("one"), validSsml("fail"), validSsml("three")];
|
|
95
|
+
const first = await synthesizeSsmlChunksSafe(client, chunks, { concurrency: 2 });
|
|
96
|
+
assert.equal(first.ok, false);
|
|
97
|
+
assert.ok(first.partialResult);
|
|
98
|
+
if (!first.ok && first.partialResult) {
|
|
99
|
+
const resumed = await synthesizeSsmlChunksSafe(client, chunks, {
|
|
100
|
+
concurrency: 2,
|
|
101
|
+
resumeChunks: first.partialResult.synthesizedChunks,
|
|
102
|
+
resumeChunkIndices: first.partialResult.pendingChunkIndices,
|
|
103
|
+
});
|
|
104
|
+
assert.equal(resumed.ok, true);
|
|
105
|
+
}
|
|
106
|
+
assert.ok(calls < 6);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("prioritizes Retry-After and supports structured per-chunk timeouts", async () => {
|
|
110
|
+
const retryAfter = new AzureTtsError(429, "Too Many Requests", "", null, { "retry-after": "2" });
|
|
111
|
+
assert.equal(getRetryAfterDelayMs(retryAfter), 2_000);
|
|
112
|
+
|
|
113
|
+
const result = await synthesizeSsmlChunksSafe(
|
|
114
|
+
{
|
|
115
|
+
synthesizeSsml: async (_ssml, options) =>
|
|
116
|
+
new Promise((_resolve, reject) =>
|
|
117
|
+
options?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }),
|
|
118
|
+
),
|
|
119
|
+
},
|
|
120
|
+
[validSsml("slow")],
|
|
121
|
+
{ timeouts: { perChunkMs: 5 } },
|
|
122
|
+
);
|
|
123
|
+
assert.equal(result.ok, false);
|
|
124
|
+
if (!result.ok) assert.equal(result.error.kind, "timeout");
|
|
125
|
+
});
|