@ssml-builder-js/azure-tts-client 2.15.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/CHANGELOG.md +11 -0
- package/dist/index.d.mts +52 -5
- package/dist/index.d.ts +52 -5
- package/dist/index.js +510 -154
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +512 -155
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +4 -0
- package/src/errors.ts +23 -1
- package/src/index.ts +12 -1
- package/src/safe.ts +238 -111
- package/src/synthesis.ts +361 -66
- package/src/types.ts +31 -0
- package/test/v216-pipeline.test.ts +110 -0
package/src/synthesis.ts
CHANGED
|
@@ -1,30 +1,45 @@
|
|
|
1
1
|
import * as SpeechSDK from "microsoft-cognitiveservices-speech-sdk";
|
|
2
2
|
import { getSsmlSourceMap } from "@ssml-builder-js/ssml-core";
|
|
3
3
|
import {
|
|
4
|
+
AzureTtsError,
|
|
4
5
|
MergeError,
|
|
6
|
+
AudioFormatMismatchError,
|
|
5
7
|
SynthesisCancelledError,
|
|
6
8
|
SynthesisTimeoutError,
|
|
7
9
|
toSynthesisError,
|
|
8
10
|
UnsupportedMergeFormatError,
|
|
9
11
|
} from "./errors.ts";
|
|
10
|
-
import { resolveMimeType, type AzureTtsOutputFormat } from "./outputFormats.ts";
|
|
12
|
+
import { DEFAULT_OUTPUT_FORMAT, resolveMimeType, type AzureTtsOutputFormat } from "./outputFormats.ts";
|
|
11
13
|
import { createSpeechConfig } from "./speechConfig.ts";
|
|
12
14
|
import type {
|
|
13
15
|
MergedSynthesisResult,
|
|
14
16
|
SsmlSynthesisChunk,
|
|
15
17
|
SsmlSynthesisResult,
|
|
18
|
+
AudioSpecification,
|
|
16
19
|
SynthesisProgressEvent,
|
|
17
20
|
TtsConfig,
|
|
21
|
+
RetryOptions,
|
|
18
22
|
} from "./types.ts";
|
|
19
23
|
|
|
20
24
|
export type MergeAudioFormat = "wav" | "mp3" | "raw";
|
|
21
25
|
|
|
22
26
|
export interface MergeAudioOptions {
|
|
23
27
|
format: AzureTtsOutputFormat;
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
outputMimeType?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type InputAudioSpecs = AudioSpecification[];
|
|
33
|
+
|
|
34
|
+
export interface CustomMergerContext {
|
|
35
|
+
format: string;
|
|
36
|
+
outputMimeType: string;
|
|
37
|
+
inputSpecs: InputAudioSpecs;
|
|
38
|
+
signal: AbortSignal;
|
|
24
39
|
}
|
|
25
40
|
|
|
26
41
|
export interface MergeSynthesisOptions extends MergeAudioOptions {
|
|
27
|
-
customMerger?: (buffers: ArrayBuffer[],
|
|
42
|
+
customMerger?: (buffers: ArrayBuffer[], context: CustomMergerContext) => Promise<ArrayBuffer> | ArrayBuffer;
|
|
28
43
|
}
|
|
29
44
|
|
|
30
45
|
type AsyncMergeSynthesisOptions = MergeSynthesisOptions & {
|
|
@@ -84,6 +99,127 @@ function parseWav(buffer: ArrayBuffer): ParsedWav {
|
|
|
84
99
|
return { chunks, data, format };
|
|
85
100
|
}
|
|
86
101
|
|
|
102
|
+
function formatNumber(format: string, pattern: RegExp, fallback: number): number {
|
|
103
|
+
const match = pattern.exec(format);
|
|
104
|
+
return match?.[1] ? Number(match[1]) : fallback;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function formatChannels(format: string, fallback: number): number {
|
|
108
|
+
if (/stereo|2ch|dual/i.test(format)) return 2;
|
|
109
|
+
if (/mono|1ch/i.test(format)) return 1;
|
|
110
|
+
return fallback;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function formatAudioSpecification(format: string): AudioSpecification {
|
|
114
|
+
const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
|
|
115
|
+
const channels = formatChannels(format, 0);
|
|
116
|
+
const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
|
|
117
|
+
const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1000 : undefined;
|
|
118
|
+
const codec: AudioSpecification["codec"] = /mp3|mpeg/i.test(format)
|
|
119
|
+
? "mp3"
|
|
120
|
+
: /opus/i.test(format)
|
|
121
|
+
? "opus"
|
|
122
|
+
: /silk/i.test(format)
|
|
123
|
+
? "silk"
|
|
124
|
+
: /pcm|mulaw|alaw|siren/i.test(format)
|
|
125
|
+
? "pcm"
|
|
126
|
+
: "unknown";
|
|
127
|
+
return {
|
|
128
|
+
format,
|
|
129
|
+
mimeType: resolveMimeType(format),
|
|
130
|
+
codec,
|
|
131
|
+
sampleRate,
|
|
132
|
+
channels,
|
|
133
|
+
...(bitrate ? { bitrate } : {}),
|
|
134
|
+
isCompressed: codec === "mp3" || codec === "opus" || codec === "silk",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseMp3Specification(buffer: ArrayBuffer, format: string): AudioSpecification | undefined {
|
|
139
|
+
const bytes = stripMp3Tags(buffer);
|
|
140
|
+
const bitrates = [
|
|
141
|
+
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
142
|
+
[0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0],
|
|
143
|
+
[0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0],
|
|
144
|
+
];
|
|
145
|
+
const sampleRates = [
|
|
146
|
+
[44_100, 48_000, 32_000],
|
|
147
|
+
[22_050, 24_000, 16_000],
|
|
148
|
+
[11_025, 12_000, 8_000],
|
|
149
|
+
];
|
|
150
|
+
for (let index = 0; index + 4 <= bytes.length; index += 1) {
|
|
151
|
+
if (bytes[index] !== 0xff || (bytes[index + 1] ?? 0) < 0xe0) continue;
|
|
152
|
+
const header = bytes[index + 1] ?? 0;
|
|
153
|
+
const versionBits = (header >> 3) & 0x03;
|
|
154
|
+
const layer = (header >> 1) & 0x03;
|
|
155
|
+
const bitrateIndex = (bytes[index + 2] ?? 0) >> 4;
|
|
156
|
+
const sampleIndex = ((bytes[index + 2] ?? 0) >> 2) & 0x03;
|
|
157
|
+
if (versionBits === 1 || layer !== 1 || bitrateIndex === 0 || bitrateIndex === 15 || sampleIndex === 3) continue;
|
|
158
|
+
const versionIndex = versionBits === 3 ? 0 : versionBits === 2 ? 1 : 2;
|
|
159
|
+
const bitrateTable = versionBits === 3 ? bitrates[1] : bitrates[2];
|
|
160
|
+
const sampleRate = sampleRates[versionIndex]?.[sampleIndex] ?? 0;
|
|
161
|
+
const bitrateKbps = bitrateTable?.[bitrateIndex] ?? 0;
|
|
162
|
+
if (!sampleRate || !bitrateKbps) continue;
|
|
163
|
+
return {
|
|
164
|
+
format,
|
|
165
|
+
mimeType: "audio/mpeg",
|
|
166
|
+
codec: "mp3",
|
|
167
|
+
sampleRate,
|
|
168
|
+
channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
|
|
169
|
+
bitrate: bitrateKbps * 1000,
|
|
170
|
+
isCompressed: true,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Extracts the stream specification from a WAV/MP3 header and output-format fallback. */
|
|
177
|
+
export function inspectAudioSpecification(buffer: ArrayBuffer, format: string): AudioSpecification {
|
|
178
|
+
if (isWavFormat(format) || ascii(new Uint8Array(buffer), 0, "RIFF")) {
|
|
179
|
+
const parsed = parseWav(buffer);
|
|
180
|
+
if (parsed.format.byteLength < 16) throw new Error("Invalid WAV fmt chunk.");
|
|
181
|
+
const view = new DataView(parsed.format.buffer, parsed.format.byteOffset, parsed.format.byteLength);
|
|
182
|
+
const sampleRate = view.getUint32(4, true);
|
|
183
|
+
const channels = view.getUint16(2, true);
|
|
184
|
+
const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
|
|
185
|
+
const formatCode = view.getUint16(0, true);
|
|
186
|
+
return {
|
|
187
|
+
format,
|
|
188
|
+
mimeType: "audio/wav",
|
|
189
|
+
codec: formatCode === 1 ? "pcm" : "unknown",
|
|
190
|
+
sampleRate,
|
|
191
|
+
channels,
|
|
192
|
+
...(sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {}),
|
|
193
|
+
isCompressed: formatCode !== 1,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
|
|
197
|
+
return formatAudioSpecification(format);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function validateAudioSpecifications(specs: readonly AudioSpecification[]): void {
|
|
201
|
+
const first = specs[0];
|
|
202
|
+
if (!first) return;
|
|
203
|
+
const mismatch = specs.find(
|
|
204
|
+
(spec) =>
|
|
205
|
+
spec.sampleRate !== first.sampleRate ||
|
|
206
|
+
spec.channels !== first.channels ||
|
|
207
|
+
(first.bitrate !== undefined && spec.bitrate !== undefined && spec.bitrate !== first.bitrate),
|
|
208
|
+
);
|
|
209
|
+
if (mismatch)
|
|
210
|
+
throw new AudioFormatMismatchError(
|
|
211
|
+
`Audio chunks have incompatible specifications: ${first.sampleRate}Hz/${first.channels}ch versus ${mismatch.sampleRate}Hz/${mismatch.channels}ch.`,
|
|
212
|
+
specs,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isAudioFormatMismatch(error: unknown): error is AudioFormatMismatchError {
|
|
217
|
+
return (
|
|
218
|
+
error instanceof AudioFormatMismatchError ||
|
|
219
|
+
(error !== null && typeof error === "object" && "kind" in error && error.kind === "audio-format-mismatch")
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
87
223
|
function writeUint32(target: Uint8Array, offset: number, value: number): void {
|
|
88
224
|
new DataView(target.buffer).setUint32(offset, value, true);
|
|
89
225
|
}
|
|
@@ -185,6 +321,7 @@ export function mergeAudioBuffers(buffers: readonly ArrayBuffer[], options: Merg
|
|
|
185
321
|
const format = typeof options === "string" ? options : options?.format;
|
|
186
322
|
if (!format) throw new UnsupportedMergeFormatError("");
|
|
187
323
|
try {
|
|
324
|
+
validateAudioSpecifications(buffers.map((buffer) => inspectAudioSpecification(buffer, format)));
|
|
188
325
|
if (isWavFormat(format)) return mergeWavBuffers(buffers);
|
|
189
326
|
if (isMp3Format(format)) {
|
|
190
327
|
const parts = buffers.map(stripMp3Tags);
|
|
@@ -207,7 +344,8 @@ export function mergeAudioBuffers(buffers: readonly ArrayBuffer[], options: Merg
|
|
|
207
344
|
}
|
|
208
345
|
throw new UnsupportedMergeFormatError(format);
|
|
209
346
|
} catch (error) {
|
|
210
|
-
if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError)
|
|
347
|
+
if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
|
|
348
|
+
throw error;
|
|
211
349
|
throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
|
|
212
350
|
}
|
|
213
351
|
}
|
|
@@ -298,6 +436,7 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
298
436
|
textRange?: { start: number; end: number };
|
|
299
437
|
originalTextRange?: { start: number; end: number };
|
|
300
438
|
sourceNodePath?: string[];
|
|
439
|
+
mappingStatus: "exact" | "fallback" | "unmapped";
|
|
301
440
|
} => {
|
|
302
441
|
const marker = markerName ? sourceMarkers.find((candidate) => candidate.name === markerName) : undefined;
|
|
303
442
|
if (marker) {
|
|
@@ -305,16 +444,25 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
305
444
|
originalTextRange: { ...marker.originalTextRange },
|
|
306
445
|
sourceNodePath: [...marker.sourceNodePath],
|
|
307
446
|
textRange: { ...marker.originalTextRange },
|
|
447
|
+
mappingStatus: "exact",
|
|
308
448
|
};
|
|
309
449
|
}
|
|
310
|
-
if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath)
|
|
450
|
+
if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) {
|
|
451
|
+
const unmapped: { mappingStatus: "unmapped" } = { mappingStatus: "unmapped" };
|
|
452
|
+
Object.defineProperty(unmapped, "mappingStatus", { value: "unmapped", enumerable: false });
|
|
453
|
+
return unmapped;
|
|
454
|
+
}
|
|
311
455
|
const value = text ?? "";
|
|
312
456
|
let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? (offsetHint as number) : -1;
|
|
313
|
-
|
|
457
|
+
let mappingStatus: "exact" | "fallback" | "unmapped" = "exact";
|
|
458
|
+
if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value) {
|
|
314
459
|
localStart = -1;
|
|
460
|
+
mappingStatus = "fallback";
|
|
461
|
+
}
|
|
315
462
|
if (localStart < 0 || localStart > sourceText.length) {
|
|
316
463
|
localStart = value ? sourceText.indexOf(value, sourceEventCursor) : sourceEventCursor;
|
|
317
464
|
if (localStart < 0) localStart = value ? sourceText.indexOf(value) : sourceEventCursor;
|
|
465
|
+
mappingStatus = "fallback";
|
|
318
466
|
}
|
|
319
467
|
localStart = Math.max(0, localStart);
|
|
320
468
|
const localEnd = Math.min(sourceText.length, localStart + value.length);
|
|
@@ -335,6 +483,7 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
335
483
|
: config.sourceNodePath
|
|
336
484
|
? { sourceNodePath: [...config.sourceNodePath] }
|
|
337
485
|
: {}),
|
|
486
|
+
mappingStatus: segment || config.sourceTextRange || config.sourceNodePath ? mappingStatus : "unmapped",
|
|
338
487
|
};
|
|
339
488
|
};
|
|
340
489
|
synthesizer.wordBoundary = (_sender, event) => {
|
|
@@ -384,22 +533,31 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
384
533
|
);
|
|
385
534
|
const durationMs = result.audioDuration ? ticksToMilliseconds(result.audioDuration) : eventDurationMs;
|
|
386
535
|
const requestId = (result as SpeechSDK.SpeechSynthesisResult & { resultId?: string }).resultId;
|
|
387
|
-
const addSourceMetadata = <T extends { audioOffsetMs: number
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
: {}),
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
536
|
+
const addSourceMetadata = <T extends { audioOffsetMs: number; mappingStatus: "exact" | "fallback" | "unmapped" }>(
|
|
537
|
+
event: T,
|
|
538
|
+
): T => {
|
|
539
|
+
const mapped = {
|
|
540
|
+
...event,
|
|
541
|
+
...(config.sourceTextRange && !("textRange" in event) ? { textRange: { ...config.sourceTextRange } } : {}),
|
|
542
|
+
...(config.sourceTextRange && !("originalTextRange" in event)
|
|
543
|
+
? { originalTextRange: { ...config.sourceTextRange } }
|
|
544
|
+
: {}),
|
|
545
|
+
...(config.chunkIndex !== undefined ? { chunkIndex: config.chunkIndex } : {}),
|
|
546
|
+
...(config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {}),
|
|
547
|
+
...(requestId ? { requestId } : {}),
|
|
548
|
+
} as T;
|
|
549
|
+
if (event.mappingStatus === "unmapped")
|
|
550
|
+
Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
|
|
551
|
+
return mapped;
|
|
552
|
+
};
|
|
397
553
|
const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
|
|
398
554
|
const sourceVisemes = visemes.map((viseme) => addSourceMetadata(viseme));
|
|
399
555
|
const sourceBookmarks = bookmarks.map((bookmark) => addSourceMetadata(bookmark));
|
|
400
556
|
resolve({
|
|
401
557
|
audioData: result.audioData,
|
|
402
558
|
durationMs,
|
|
559
|
+
audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
|
|
560
|
+
mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
|
|
403
561
|
...(config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {}),
|
|
404
562
|
...(requestId ? { requestId } : {}),
|
|
405
563
|
...(sourceBoundaries.length > 0
|
|
@@ -428,12 +586,82 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
428
586
|
});
|
|
429
587
|
}
|
|
430
588
|
|
|
431
|
-
|
|
589
|
+
function isRetryableSynthesisError(error: unknown): boolean {
|
|
590
|
+
if (error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError) return false;
|
|
591
|
+
if (error instanceof AzureTtsError && error.status !== 0)
|
|
592
|
+
return error.status === 429 || (error.status >= 500 && error.status < 600);
|
|
593
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
594
|
+
if (/\b4\d{2}\b/.test(message)) return false;
|
|
595
|
+
const status = error && typeof error === "object" && "status" in error ? error.status : undefined;
|
|
596
|
+
if (typeof status === "number") return status === 429 || (status >= 500 && status < 600);
|
|
597
|
+
return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function retryDelay(options: RetryOptions, retryAttempt: number): number {
|
|
601
|
+
const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
|
|
602
|
+
return Math.floor(Math.random() * (base + 1));
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function resolveConcurrency(value: number | undefined, total: number): number {
|
|
606
|
+
if (value === undefined) return 1;
|
|
607
|
+
if (value === Infinity) return Math.max(1, total);
|
|
608
|
+
return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function waitForRetry(delayMs: number, signal?: AbortSignal): Promise<void> {
|
|
612
|
+
if (signal?.aborted) throw new SynthesisCancelledError();
|
|
613
|
+
if (delayMs <= 0) return;
|
|
614
|
+
await new Promise<void>((resolve, reject) => {
|
|
615
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
616
|
+
const abort = () => {
|
|
617
|
+
clearTimeout(timer);
|
|
618
|
+
signal?.removeEventListener("abort", abort);
|
|
619
|
+
reject(new SynthesisCancelledError());
|
|
620
|
+
};
|
|
621
|
+
timer = setTimeout(() => {
|
|
622
|
+
signal?.removeEventListener("abort", abort);
|
|
623
|
+
resolve();
|
|
624
|
+
}, delayMs);
|
|
625
|
+
if (signal) {
|
|
626
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function synthesizeWithRetry(
|
|
632
|
+
ssml: string,
|
|
633
|
+
config: TtsConfig,
|
|
634
|
+
retryOptions: RetryOptions | undefined,
|
|
635
|
+
onRetry: (retryAttempt: number, nextRetryDelayMs: number) => void,
|
|
636
|
+
): Promise<SsmlSynthesisResult> {
|
|
637
|
+
const options = retryOptions
|
|
638
|
+
? {
|
|
639
|
+
maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
|
|
640
|
+
initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
|
|
641
|
+
maxDelayMs: Math.max(0, retryOptions.maxDelayMs),
|
|
642
|
+
}
|
|
643
|
+
: undefined;
|
|
644
|
+
let attempt = 0;
|
|
645
|
+
while (true) {
|
|
646
|
+
if (config.signal?.aborted) throw new SynthesisCancelledError();
|
|
647
|
+
try {
|
|
648
|
+
return await synthesizeSsml(ssml, config);
|
|
649
|
+
} catch (error) {
|
|
650
|
+
if (!options || attempt >= options.maxRetries || !isRetryableSynthesisError(error)) throw error;
|
|
651
|
+
attempt += 1;
|
|
652
|
+
const delayMs = retryDelay(options, attempt);
|
|
653
|
+
onRetry(attempt, delayMs);
|
|
654
|
+
await waitForRetry(delayMs, config.signal);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Synthesizes chunks with bounded concurrency, retries transient failures, and merges in chunk order. */
|
|
432
660
|
export async function synthesizeSsmlChunks(
|
|
433
661
|
chunks: readonly (SsmlSynthesisChunk | string)[],
|
|
434
662
|
config: TtsConfig,
|
|
435
663
|
): Promise<SsmlSynthesisResult> {
|
|
436
|
-
const results: SsmlSynthesisResult
|
|
664
|
+
const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
|
|
437
665
|
const totalChunks = chunks.length;
|
|
438
666
|
const report = (event: SynthesisProgressEvent): void => config.onProgress?.(event);
|
|
439
667
|
for (const [index, chunk] of chunks.entries()) {
|
|
@@ -448,56 +676,85 @@ export async function synthesizeSsmlChunks(
|
|
|
448
676
|
durationMs: 0,
|
|
449
677
|
});
|
|
450
678
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
durationMs: 0,
|
|
461
|
-
});
|
|
462
|
-
const startedAt = Date.now();
|
|
463
|
-
try {
|
|
464
|
-
const result = await synthesizeSsml(input.ssml, {
|
|
465
|
-
...config,
|
|
466
|
-
...(input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {}),
|
|
467
|
-
...((input.sourceNodePath ?? config.sourceNodePath)
|
|
468
|
-
? { sourceNodePath: [...(input.sourceNodePath ?? config.sourceNodePath ?? [])] }
|
|
469
|
-
: {}),
|
|
470
|
-
...(input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {}),
|
|
471
|
-
...(input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {}),
|
|
472
|
-
chunkIndex: index,
|
|
473
|
-
onProgress: undefined,
|
|
474
|
-
});
|
|
475
|
-
results.push(result);
|
|
476
|
-
report({
|
|
477
|
-
currentChunk: index + 1,
|
|
478
|
-
totalChunks,
|
|
479
|
-
percent: totalChunks === 0 ? 100 : Math.round(((index + 1) / totalChunks) * 100),
|
|
480
|
-
chunkIndex: index,
|
|
481
|
-
originalTextRange: input.originalTextRange,
|
|
482
|
-
status: "success",
|
|
483
|
-
durationMs: Date.now() - startedAt,
|
|
484
|
-
});
|
|
485
|
-
} catch (error) {
|
|
679
|
+
let completed = 0;
|
|
680
|
+
let nextIndex = 0;
|
|
681
|
+
const concurrency = resolveConcurrency(config.concurrency, chunks.length);
|
|
682
|
+
const worker = async (): Promise<void> => {
|
|
683
|
+
while (true) {
|
|
684
|
+
const index = nextIndex++;
|
|
685
|
+
if (index >= chunks.length) return;
|
|
686
|
+
const chunk = chunks[index];
|
|
687
|
+
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
486
688
|
report({
|
|
487
|
-
currentChunk:
|
|
689
|
+
currentChunk: completed,
|
|
488
690
|
totalChunks,
|
|
489
|
-
percent: totalChunks === 0 ? 100 : Math.round((
|
|
691
|
+
percent: totalChunks === 0 ? 100 : Math.round((completed / totalChunks) * 100),
|
|
490
692
|
chunkIndex: index,
|
|
491
693
|
originalTextRange: input.originalTextRange,
|
|
492
|
-
status: "
|
|
493
|
-
durationMs:
|
|
494
|
-
error,
|
|
694
|
+
status: "synthesizing",
|
|
695
|
+
durationMs: 0,
|
|
495
696
|
});
|
|
496
|
-
|
|
697
|
+
const startedAt = Date.now();
|
|
698
|
+
try {
|
|
699
|
+
const result = await synthesizeWithRetry(
|
|
700
|
+
input.ssml,
|
|
701
|
+
{
|
|
702
|
+
...config,
|
|
703
|
+
...(input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {}),
|
|
704
|
+
...((input.sourceNodePath ?? config.sourceNodePath)
|
|
705
|
+
? { sourceNodePath: [...(input.sourceNodePath ?? config.sourceNodePath ?? [])] }
|
|
706
|
+
: {}),
|
|
707
|
+
...(input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {}),
|
|
708
|
+
...(input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {}),
|
|
709
|
+
chunkIndex: index,
|
|
710
|
+
onProgress: undefined,
|
|
711
|
+
},
|
|
712
|
+
config.retryOptions,
|
|
713
|
+
(retryAttempt, nextRetryDelayMs) =>
|
|
714
|
+
report({
|
|
715
|
+
currentChunk: completed,
|
|
716
|
+
totalChunks,
|
|
717
|
+
percent: totalChunks === 0 ? 100 : Math.round((completed / totalChunks) * 100),
|
|
718
|
+
chunkIndex: index,
|
|
719
|
+
originalTextRange: input.originalTextRange,
|
|
720
|
+
status: "synthesizing",
|
|
721
|
+
durationMs: Date.now() - startedAt,
|
|
722
|
+
retryAttempt,
|
|
723
|
+
nextRetryDelayMs,
|
|
724
|
+
isRetrying: true,
|
|
725
|
+
}),
|
|
726
|
+
);
|
|
727
|
+
results[index] = result;
|
|
728
|
+
completed += 1;
|
|
729
|
+
report({
|
|
730
|
+
currentChunk: completed,
|
|
731
|
+
totalChunks,
|
|
732
|
+
percent: totalChunks === 0 ? 100 : Math.round((completed / totalChunks) * 100),
|
|
733
|
+
chunkIndex: index,
|
|
734
|
+
originalTextRange: input.originalTextRange,
|
|
735
|
+
status: "success",
|
|
736
|
+
durationMs: Date.now() - startedAt,
|
|
737
|
+
});
|
|
738
|
+
} catch (error) {
|
|
739
|
+
report({
|
|
740
|
+
currentChunk: completed,
|
|
741
|
+
totalChunks,
|
|
742
|
+
percent: totalChunks === 0 ? 100 : Math.round((completed / totalChunks) * 100),
|
|
743
|
+
chunkIndex: index,
|
|
744
|
+
originalTextRange: input.originalTextRange,
|
|
745
|
+
status: "failed",
|
|
746
|
+
durationMs: Date.now() - startedAt,
|
|
747
|
+
error,
|
|
748
|
+
});
|
|
749
|
+
throw error;
|
|
750
|
+
}
|
|
497
751
|
}
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
|
|
752
|
+
};
|
|
753
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, chunks.length) }, () => worker()));
|
|
754
|
+
const orderedResults = results.filter((result): result is SsmlSynthesisResult => result !== undefined);
|
|
755
|
+
return mergeSynthesisResults(orderedResults, {
|
|
756
|
+
format: (config.outputFormat ?? DEFAULT_OUTPUT_FORMAT) as AzureTtsOutputFormat,
|
|
757
|
+
signal: config.signal,
|
|
501
758
|
});
|
|
502
759
|
}
|
|
503
760
|
|
|
@@ -506,6 +763,8 @@ function createMergedResult(
|
|
|
506
763
|
results: readonly SsmlSynthesisResult[],
|
|
507
764
|
audioData: ArrayBuffer,
|
|
508
765
|
format: string,
|
|
766
|
+
audioSpec?: AudioSpecification,
|
|
767
|
+
outputMimeType?: string,
|
|
509
768
|
): MergedSynthesisResult {
|
|
510
769
|
const boundaries: NonNullable<SsmlSynthesisResult["boundaries"]> = [];
|
|
511
770
|
const visemes: NonNullable<SsmlSynthesisResult["visemes"]> = [];
|
|
@@ -530,6 +789,7 @@ function createMergedResult(
|
|
|
530
789
|
...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
|
|
531
790
|
...(textRange ? { textRange: { ...textRange } } : {}),
|
|
532
791
|
...(requestId ? { requestId } : {}),
|
|
792
|
+
mappingStatus: boundary.mappingStatus ?? "unmapped",
|
|
533
793
|
});
|
|
534
794
|
}
|
|
535
795
|
for (const viseme of result.visemes ?? []) {
|
|
@@ -545,6 +805,7 @@ function createMergedResult(
|
|
|
545
805
|
...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
|
|
546
806
|
...(textRange ? { textRange: { ...textRange } } : {}),
|
|
547
807
|
...(requestId ? { requestId } : {}),
|
|
808
|
+
mappingStatus: viseme.mappingStatus ?? "unmapped",
|
|
548
809
|
});
|
|
549
810
|
}
|
|
550
811
|
for (const bookmark of result.bookmarks ?? []) {
|
|
@@ -560,6 +821,7 @@ function createMergedResult(
|
|
|
560
821
|
...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
|
|
561
822
|
...(textRange ? { textRange: { ...textRange } } : {}),
|
|
562
823
|
...(requestId ? { requestId } : {}),
|
|
824
|
+
mappingStatus: bookmark.mappingStatus ?? "unmapped",
|
|
563
825
|
});
|
|
564
826
|
}
|
|
565
827
|
durationOffset += Math.max(0, result.durationMs);
|
|
@@ -569,6 +831,8 @@ function createMergedResult(
|
|
|
569
831
|
audioData,
|
|
570
832
|
durationMs: durationOffset,
|
|
571
833
|
mimeType: resolveMimeType(format),
|
|
834
|
+
audioSpec: audioSpec ?? formatAudioSpecification(format),
|
|
835
|
+
...(outputMimeType ? { mimeType: outputMimeType } : {}),
|
|
572
836
|
...(boundaries.length > 0 ? { boundaries, wordBoundary: boundaries, wordBoundaries: boundaries } : {}),
|
|
573
837
|
...(visemes.length > 0 ? { visemes } : {}),
|
|
574
838
|
...(bookmarks.length > 0 ? { bookmarks } : {}),
|
|
@@ -594,22 +858,53 @@ export function mergeSynthesisResults(
|
|
|
594
858
|
const format = resolvedOptions?.format;
|
|
595
859
|
if (!format) throw new UnsupportedMergeFormatError("");
|
|
596
860
|
const buffers = results.map((result) => result.audioData);
|
|
861
|
+
const inputSpecs = results.map((result) => result.audioSpec ?? inspectAudioSpecification(result.audioData, format));
|
|
862
|
+
validateAudioSpecifications(inputSpecs);
|
|
863
|
+
const signal = resolvedOptions.signal ?? new AbortController().signal;
|
|
864
|
+
if (signal.aborted) throw new SynthesisCancelledError();
|
|
597
865
|
if (resolvedOptions.customMerger) {
|
|
598
866
|
return Promise.resolve()
|
|
599
|
-
.then(() =>
|
|
867
|
+
.then(() =>
|
|
868
|
+
resolvedOptions.customMerger?.(buffers, {
|
|
869
|
+
format,
|
|
870
|
+
outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
|
|
871
|
+
inputSpecs,
|
|
872
|
+
signal,
|
|
873
|
+
}),
|
|
874
|
+
)
|
|
600
875
|
.then((merged) => {
|
|
601
876
|
if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
|
|
602
|
-
|
|
877
|
+
if (
|
|
878
|
+
!(merged instanceof ArrayBuffer) ||
|
|
879
|
+
(buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
|
|
880
|
+
)
|
|
881
|
+
throw new MergeError("The custom audio merger returned an invalid audio buffer.");
|
|
882
|
+
if (signal.aborted) throw new SynthesisCancelledError();
|
|
883
|
+
return createMergedResult(
|
|
884
|
+
results,
|
|
885
|
+
merged,
|
|
886
|
+
format,
|
|
887
|
+
inspectAudioSpecification(merged, format),
|
|
888
|
+
resolvedOptions.outputMimeType,
|
|
889
|
+
);
|
|
603
890
|
})
|
|
604
891
|
.catch((error: unknown) => {
|
|
605
|
-
if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError)
|
|
892
|
+
if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
|
|
893
|
+
throw error;
|
|
606
894
|
throw new MergeError(`Custom audio merger failed for format "${format}".`, error);
|
|
607
895
|
});
|
|
608
896
|
}
|
|
609
897
|
try {
|
|
610
|
-
return createMergedResult(
|
|
898
|
+
return createMergedResult(
|
|
899
|
+
results,
|
|
900
|
+
mergeAudioBuffers(buffers, { format }),
|
|
901
|
+
format,
|
|
902
|
+
inputSpecs[0],
|
|
903
|
+
resolvedOptions.outputMimeType,
|
|
904
|
+
);
|
|
611
905
|
} catch (error) {
|
|
612
|
-
if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError)
|
|
906
|
+
if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
|
|
907
|
+
throw error;
|
|
613
908
|
throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
|
|
614
909
|
}
|
|
615
910
|
}
|
package/src/types.ts
CHANGED
|
@@ -18,6 +18,26 @@ export interface TtsConfig {
|
|
|
18
18
|
/** Exact source text segments used to map individual Azure events. */
|
|
19
19
|
sourceTextSegments?: SsmlSourceTextSegment[];
|
|
20
20
|
sourceMarkers?: SsmlSourceMarker[];
|
|
21
|
+
concurrency?: number;
|
|
22
|
+
retryOptions?: RetryOptions;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type MappingStatus = "exact" | "fallback" | "unmapped";
|
|
26
|
+
|
|
27
|
+
export interface AudioSpecification {
|
|
28
|
+
format: string;
|
|
29
|
+
mimeType: string;
|
|
30
|
+
codec: "pcm" | "mp3" | "opus" | "silk" | "unknown";
|
|
31
|
+
sampleRate: number;
|
|
32
|
+
channels: number;
|
|
33
|
+
bitrate?: number;
|
|
34
|
+
isCompressed: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RetryOptions {
|
|
38
|
+
maxRetries: number;
|
|
39
|
+
initialDelayMs: number;
|
|
40
|
+
maxDelayMs: number;
|
|
21
41
|
}
|
|
22
42
|
|
|
23
43
|
export type SynthesisChunkStatus = "pending" | "synthesizing" | "success" | "failed";
|
|
@@ -36,6 +56,7 @@ export interface SsmlSynthesisBoundary {
|
|
|
36
56
|
/** Audio offset within the originating chunk before merge. */
|
|
37
57
|
chunkAudioOffsetMs?: number;
|
|
38
58
|
requestId?: string;
|
|
59
|
+
mappingStatus: MappingStatus;
|
|
39
60
|
}
|
|
40
61
|
|
|
41
62
|
export interface SsmlSynthesisViseme {
|
|
@@ -47,6 +68,7 @@ export interface SsmlSynthesisViseme {
|
|
|
47
68
|
originalTextRange?: SsmlTextRange;
|
|
48
69
|
chunkAudioOffsetMs?: number;
|
|
49
70
|
requestId?: string;
|
|
71
|
+
mappingStatus: MappingStatus;
|
|
50
72
|
}
|
|
51
73
|
|
|
52
74
|
export interface SsmlSynthesisBookmark {
|
|
@@ -58,6 +80,7 @@ export interface SsmlSynthesisBookmark {
|
|
|
58
80
|
originalTextRange?: SsmlTextRange;
|
|
59
81
|
chunkAudioOffsetMs?: number;
|
|
60
82
|
requestId?: string;
|
|
83
|
+
mappingStatus: MappingStatus;
|
|
61
84
|
}
|
|
62
85
|
|
|
63
86
|
/** Audio and Azure Speech synchronization events emitted for one SSML request. */
|
|
@@ -77,6 +100,7 @@ export interface SsmlSynthesisResult {
|
|
|
77
100
|
textRange?: { start: number; end: number };
|
|
78
101
|
/** MIME type of a result produced by an explicit merge operation. */
|
|
79
102
|
mimeType?: string;
|
|
103
|
+
audioSpec?: AudioSpecification;
|
|
80
104
|
}
|
|
81
105
|
|
|
82
106
|
export interface MergedSynthesisResult extends SsmlSynthesisResult {
|
|
@@ -97,6 +121,8 @@ export interface SynthesizeChunksOptions {
|
|
|
97
121
|
signal?: AbortSignal;
|
|
98
122
|
timeoutMs?: number;
|
|
99
123
|
sourceNodePath?: string[];
|
|
124
|
+
concurrency?: number;
|
|
125
|
+
retryOptions?: RetryOptions;
|
|
100
126
|
}
|
|
101
127
|
|
|
102
128
|
export interface SynthesisProgressEvent {
|
|
@@ -109,6 +135,9 @@ export interface SynthesisProgressEvent {
|
|
|
109
135
|
status: SynthesisChunkStatus;
|
|
110
136
|
durationMs: number;
|
|
111
137
|
error?: unknown;
|
|
138
|
+
retryAttempt?: number;
|
|
139
|
+
nextRetryDelayMs?: number;
|
|
140
|
+
isRetrying?: boolean;
|
|
112
141
|
}
|
|
113
142
|
|
|
114
143
|
export interface AzureTtsLogger {
|
|
@@ -127,4 +156,6 @@ export interface AzureTtsClientOptions {
|
|
|
127
156
|
outputFormat?: string;
|
|
128
157
|
logger?: AzureTtsLogger;
|
|
129
158
|
onProgress?: (event: SynthesisProgressEvent) => void;
|
|
159
|
+
concurrency?: number;
|
|
160
|
+
retryOptions?: RetryOptions;
|
|
130
161
|
}
|