@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/CHANGELOG.md +22 -0
- package/dist/index.d.mts +161 -18
- package/dist/index.d.ts +161 -18
- package/dist/index.js +754 -174
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +749 -173
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +23 -5
- package/src/errors.ts +85 -0
- package/src/index.ts +25 -2
- package/src/outputFormats.ts +14 -3
- package/src/safe.ts +330 -81
- package/src/synthesis.ts +555 -97
- package/src/types.ts +48 -1
- package/test/synthesis.test.ts +64 -0
- package/test/v213-pipeline.test.ts +23 -16
- package/test/v214-pipeline.test.ts +7 -3
- package/test/v215-pipeline.test.ts +104 -0
- package/test/v216-pipeline.test.ts +110 -0
package/src/synthesis.ts
CHANGED
|
@@ -1,10 +1,51 @@
|
|
|
1
1
|
import * as SpeechSDK from "microsoft-cognitiveservices-speech-sdk";
|
|
2
|
-
import {
|
|
2
|
+
import { getSsmlSourceMap } from "@ssml-builder-js/ssml-core";
|
|
3
|
+
import {
|
|
4
|
+
AzureTtsError,
|
|
5
|
+
MergeError,
|
|
6
|
+
AudioFormatMismatchError,
|
|
7
|
+
SynthesisCancelledError,
|
|
8
|
+
SynthesisTimeoutError,
|
|
9
|
+
toSynthesisError,
|
|
10
|
+
UnsupportedMergeFormatError,
|
|
11
|
+
} from "./errors.ts";
|
|
12
|
+
import { DEFAULT_OUTPUT_FORMAT, resolveMimeType, type AzureTtsOutputFormat } from "./outputFormats.ts";
|
|
3
13
|
import { createSpeechConfig } from "./speechConfig.ts";
|
|
4
|
-
import type {
|
|
14
|
+
import type {
|
|
15
|
+
MergedSynthesisResult,
|
|
16
|
+
SsmlSynthesisChunk,
|
|
17
|
+
SsmlSynthesisResult,
|
|
18
|
+
AudioSpecification,
|
|
19
|
+
SynthesisProgressEvent,
|
|
20
|
+
TtsConfig,
|
|
21
|
+
RetryOptions,
|
|
22
|
+
} from "./types.ts";
|
|
5
23
|
|
|
6
24
|
export type MergeAudioFormat = "wav" | "mp3" | "raw";
|
|
7
25
|
|
|
26
|
+
export interface MergeAudioOptions {
|
|
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;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MergeSynthesisOptions extends MergeAudioOptions {
|
|
42
|
+
customMerger?: (buffers: ArrayBuffer[], context: CustomMergerContext) => Promise<ArrayBuffer> | ArrayBuffer;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type AsyncMergeSynthesisOptions = MergeSynthesisOptions & {
|
|
46
|
+
customMerger: NonNullable<MergeSynthesisOptions["customMerger"]>;
|
|
47
|
+
};
|
|
48
|
+
|
|
8
49
|
function ascii(bytes: Uint8Array, offset: number, value: string): boolean {
|
|
9
50
|
return [...value].every((character, index) => bytes[offset + index] === character.charCodeAt(0));
|
|
10
51
|
}
|
|
@@ -58,6 +99,127 @@ function parseWav(buffer: ArrayBuffer): ParsedWav {
|
|
|
58
99
|
return { chunks, data, format };
|
|
59
100
|
}
|
|
60
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
|
+
|
|
61
223
|
function writeUint32(target: Uint8Array, offset: number, value: number): void {
|
|
62
224
|
new DataView(target.buffer).setUint32(offset, value, true);
|
|
63
225
|
}
|
|
@@ -154,28 +316,38 @@ export function canMergeAudioFormat(format: string): boolean {
|
|
|
154
316
|
}
|
|
155
317
|
|
|
156
318
|
/** Merges audio buffers while preserving the invariants of supported containers. */
|
|
157
|
-
export function mergeAudioBuffers(buffers: readonly ArrayBuffer[],
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
319
|
+
export function mergeAudioBuffers(buffers: readonly ArrayBuffer[], options: MergeAudioOptions): ArrayBuffer;
|
|
320
|
+
export function mergeAudioBuffers(buffers: readonly ArrayBuffer[], options: MergeAudioOptions | string): ArrayBuffer {
|
|
321
|
+
const format = typeof options === "string" ? options : options?.format;
|
|
322
|
+
if (!format) throw new UnsupportedMergeFormatError("");
|
|
323
|
+
try {
|
|
324
|
+
validateAudioSpecifications(buffers.map((buffer) => inspectAudioSpecification(buffer, format)));
|
|
325
|
+
if (isWavFormat(format)) return mergeWavBuffers(buffers);
|
|
326
|
+
if (isMp3Format(format)) {
|
|
327
|
+
const parts = buffers.map(stripMp3Tags);
|
|
328
|
+
const output = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0));
|
|
329
|
+
let offset = 0;
|
|
330
|
+
for (const part of parts) {
|
|
331
|
+
output.set(part, offset);
|
|
332
|
+
offset += part.byteLength;
|
|
333
|
+
}
|
|
334
|
+
return output.buffer;
|
|
166
335
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
336
|
+
if (isRawFormat(format)) {
|
|
337
|
+
const output = new Uint8Array(buffers.reduce((total, buffer) => total + buffer.byteLength, 0));
|
|
338
|
+
let offset = 0;
|
|
339
|
+
for (const buffer of buffers) {
|
|
340
|
+
output.set(new Uint8Array(buffer), offset);
|
|
341
|
+
offset += buffer.byteLength;
|
|
342
|
+
}
|
|
343
|
+
return output.buffer;
|
|
175
344
|
}
|
|
176
|
-
|
|
345
|
+
throw new UnsupportedMergeFormatError(format);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
|
|
348
|
+
throw error;
|
|
349
|
+
throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
|
|
177
350
|
}
|
|
178
|
-
throw new UnsupportedMergeFormatError(format);
|
|
179
351
|
}
|
|
180
352
|
|
|
181
353
|
function closeSpeechResources(speechConfig: SpeechSDK.SpeechConfig, synthesizer: SpeechSDK.SpeechSynthesizer): void {
|
|
@@ -192,7 +364,7 @@ const ticksToMilliseconds = (ticks: number): number => Math.max(0, ticks) / 10_0
|
|
|
192
364
|
|
|
193
365
|
export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<SsmlSynthesisResult> {
|
|
194
366
|
if (config.signal?.aborted) {
|
|
195
|
-
throw
|
|
367
|
+
throw new SynthesisCancelledError();
|
|
196
368
|
}
|
|
197
369
|
|
|
198
370
|
const speechConfig = createSpeechConfig(config);
|
|
@@ -217,24 +389,129 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
217
389
|
settled = true;
|
|
218
390
|
cleanup();
|
|
219
391
|
closeResources();
|
|
220
|
-
reject(
|
|
392
|
+
reject(toSynthesisError(error));
|
|
221
393
|
};
|
|
222
394
|
|
|
223
395
|
const boundaries: SsmlSynthesisResult["boundaries"] = [];
|
|
224
396
|
const visemes: SsmlSynthesisResult["visemes"] = [];
|
|
225
397
|
const bookmarks: SsmlSynthesisResult["bookmarks"] = [];
|
|
398
|
+
let sourceEventCursor = 0;
|
|
399
|
+
let generatedSourceMap: ReturnType<typeof getSsmlSourceMap> | undefined;
|
|
400
|
+
if (!config.sourceTextSegments && !config.sourceMarkers) {
|
|
401
|
+
try {
|
|
402
|
+
generatedSourceMap = getSsmlSourceMap(ssml);
|
|
403
|
+
} catch {
|
|
404
|
+
generatedSourceMap = undefined;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const sourceBaseOffset = config.sourceTextRange?.start ?? 0;
|
|
408
|
+
const sourceSegments =
|
|
409
|
+
config.sourceTextSegments ??
|
|
410
|
+
generatedSourceMap?.segments.map((segment) => ({
|
|
411
|
+
...segment,
|
|
412
|
+
range: {
|
|
413
|
+
start: segment.range.start + sourceBaseOffset,
|
|
414
|
+
end: segment.range.end + sourceBaseOffset,
|
|
415
|
+
},
|
|
416
|
+
sourceNodePath: [...segment.sourceNodePath],
|
|
417
|
+
})) ??
|
|
418
|
+
[];
|
|
419
|
+
const sourceMarkers =
|
|
420
|
+
config.sourceMarkers ??
|
|
421
|
+
generatedSourceMap?.markers.map((marker) => ({
|
|
422
|
+
...marker,
|
|
423
|
+
originalTextRange: {
|
|
424
|
+
start: marker.originalTextRange.start + sourceBaseOffset,
|
|
425
|
+
end: marker.originalTextRange.end + sourceBaseOffset,
|
|
426
|
+
},
|
|
427
|
+
sourceNodePath: [...marker.sourceNodePath],
|
|
428
|
+
})) ??
|
|
429
|
+
[];
|
|
430
|
+
const sourceText = sourceSegments.map((segment) => segment.text).join("");
|
|
431
|
+
const mapSourceEvent = (
|
|
432
|
+
text: string | undefined,
|
|
433
|
+
offsetHint: number | undefined,
|
|
434
|
+
markerName?: string,
|
|
435
|
+
): {
|
|
436
|
+
textRange?: { start: number; end: number };
|
|
437
|
+
originalTextRange?: { start: number; end: number };
|
|
438
|
+
sourceNodePath?: string[];
|
|
439
|
+
mappingStatus: "exact" | "fallback" | "unmapped";
|
|
440
|
+
} => {
|
|
441
|
+
const marker = markerName ? sourceMarkers.find((candidate) => candidate.name === markerName) : undefined;
|
|
442
|
+
if (marker) {
|
|
443
|
+
return {
|
|
444
|
+
originalTextRange: { ...marker.originalTextRange },
|
|
445
|
+
sourceNodePath: [...marker.sourceNodePath],
|
|
446
|
+
textRange: { ...marker.originalTextRange },
|
|
447
|
+
mappingStatus: "exact",
|
|
448
|
+
};
|
|
449
|
+
}
|
|
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
|
+
}
|
|
455
|
+
const value = text ?? "";
|
|
456
|
+
let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? (offsetHint as number) : -1;
|
|
457
|
+
let mappingStatus: "exact" | "fallback" | "unmapped" = "exact";
|
|
458
|
+
if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value) {
|
|
459
|
+
localStart = -1;
|
|
460
|
+
mappingStatus = "fallback";
|
|
461
|
+
}
|
|
462
|
+
if (localStart < 0 || localStart > sourceText.length) {
|
|
463
|
+
localStart = value ? sourceText.indexOf(value, sourceEventCursor) : sourceEventCursor;
|
|
464
|
+
if (localStart < 0) localStart = value ? sourceText.indexOf(value) : sourceEventCursor;
|
|
465
|
+
mappingStatus = "fallback";
|
|
466
|
+
}
|
|
467
|
+
localStart = Math.max(0, localStart);
|
|
468
|
+
const localEnd = Math.min(sourceText.length, localStart + value.length);
|
|
469
|
+
sourceEventCursor = Math.max(sourceEventCursor, localEnd);
|
|
470
|
+
const baseStart = config.sourceTextRange?.start ?? sourceSegments[0]?.range.start ?? 0;
|
|
471
|
+
const fallbackRange = { start: baseStart + localStart, end: baseStart + localEnd };
|
|
472
|
+
const segment =
|
|
473
|
+
sourceSegments.find(({ range }) => range.start <= fallbackRange.start && range.end > fallbackRange.start) ??
|
|
474
|
+
sourceSegments.find(({ range }) => range.end > fallbackRange.start) ??
|
|
475
|
+
(value.length === 0
|
|
476
|
+
? sourceSegments.find(({ range }) => range.start <= fallbackRange.start && range.end >= fallbackRange.start)
|
|
477
|
+
: undefined);
|
|
478
|
+
return {
|
|
479
|
+
originalTextRange: { ...fallbackRange },
|
|
480
|
+
textRange: { ...fallbackRange },
|
|
481
|
+
...(segment
|
|
482
|
+
? { sourceNodePath: [...segment.sourceNodePath] }
|
|
483
|
+
: config.sourceNodePath
|
|
484
|
+
? { sourceNodePath: [...config.sourceNodePath] }
|
|
485
|
+
: {}),
|
|
486
|
+
mappingStatus: segment || config.sourceTextRange || config.sourceNodePath ? mappingStatus : "unmapped",
|
|
487
|
+
};
|
|
488
|
+
};
|
|
226
489
|
synthesizer.wordBoundary = (_sender, event) => {
|
|
227
490
|
boundaries.push({
|
|
228
491
|
text: event.text,
|
|
229
492
|
audioOffsetMs: ticksToMilliseconds(event.audioOffset),
|
|
230
493
|
durationMs: ticksToMilliseconds(event.duration),
|
|
494
|
+
...mapSourceEvent(
|
|
495
|
+
event.text,
|
|
496
|
+
(event as SpeechSDK.SpeechSynthesisWordBoundaryEventArgs & { textOffset?: number }).textOffset,
|
|
497
|
+
),
|
|
231
498
|
});
|
|
232
499
|
};
|
|
233
500
|
synthesizer.visemeReceived = (_sender, event) => {
|
|
234
|
-
|
|
501
|
+
const eventWithOffset = event as SpeechSDK.SpeechSynthesisVisemeEventArgs & { textOffset?: number };
|
|
502
|
+
visemes.push({
|
|
503
|
+
visemeId: event.visemeId,
|
|
504
|
+
audioOffsetMs: ticksToMilliseconds(event.audioOffset),
|
|
505
|
+
...mapSourceEvent(undefined, eventWithOffset.textOffset),
|
|
506
|
+
});
|
|
235
507
|
};
|
|
236
508
|
synthesizer.bookmarkReached = (_sender, event) => {
|
|
237
|
-
|
|
509
|
+
const eventWithOffset = event as SpeechSDK.SpeechSynthesisBookmarkEventArgs & { textOffset?: number };
|
|
510
|
+
bookmarks.push({
|
|
511
|
+
name: event.text,
|
|
512
|
+
audioOffsetMs: ticksToMilliseconds(event.audioOffset),
|
|
513
|
+
...mapSourceEvent(undefined, eventWithOffset.textOffset, event.text),
|
|
514
|
+
});
|
|
238
515
|
};
|
|
239
516
|
|
|
240
517
|
const cb = (result: SpeechSDK.SpeechSynthesisResult) => {
|
|
@@ -256,20 +533,31 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
256
533
|
);
|
|
257
534
|
const durationMs = result.audioDuration ? ticksToMilliseconds(result.audioDuration) : eventDurationMs;
|
|
258
535
|
const requestId = (result as SpeechSDK.SpeechSynthesisResult & { resultId?: string }).resultId;
|
|
259
|
-
const addSourceMetadata = <T extends { audioOffsetMs: number
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
+
};
|
|
267
553
|
const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
|
|
268
554
|
const sourceVisemes = visemes.map((viseme) => addSourceMetadata(viseme));
|
|
269
555
|
const sourceBookmarks = bookmarks.map((bookmark) => addSourceMetadata(bookmark));
|
|
270
556
|
resolve({
|
|
271
557
|
audioData: result.audioData,
|
|
272
558
|
durationMs,
|
|
559
|
+
audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
|
|
560
|
+
mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
|
|
273
561
|
...(config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {}),
|
|
274
562
|
...(requestId ? { requestId } : {}),
|
|
275
563
|
...(sourceBoundaries.length > 0
|
|
@@ -282,12 +570,12 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
282
570
|
|
|
283
571
|
try {
|
|
284
572
|
if (config.signal) {
|
|
285
|
-
abortHandler = () => rejectWithError(
|
|
573
|
+
abortHandler = () => rejectWithError(new SynthesisCancelledError());
|
|
286
574
|
config.signal.addEventListener("abort", abortHandler, { once: true });
|
|
287
575
|
}
|
|
288
576
|
if (config.timeoutMs !== undefined && config.timeoutMs > 0) {
|
|
289
577
|
timeout = setTimeout(
|
|
290
|
-
() => rejectWithError(`Speech synthesis timed out after ${config.timeoutMs} ms.`),
|
|
578
|
+
() => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeoutMs} ms.`)),
|
|
291
579
|
config.timeoutMs,
|
|
292
580
|
);
|
|
293
581
|
}
|
|
@@ -298,12 +586,82 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
298
586
|
});
|
|
299
587
|
}
|
|
300
588
|
|
|
301
|
-
|
|
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. */
|
|
302
660
|
export async function synthesizeSsmlChunks(
|
|
303
661
|
chunks: readonly (SsmlSynthesisChunk | string)[],
|
|
304
662
|
config: TtsConfig,
|
|
305
663
|
): Promise<SsmlSynthesisResult> {
|
|
306
|
-
const results: SsmlSynthesisResult
|
|
664
|
+
const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
|
|
307
665
|
const totalChunks = chunks.length;
|
|
308
666
|
const report = (event: SynthesisProgressEvent): void => config.onProgress?.(event);
|
|
309
667
|
for (const [index, chunk] of chunks.entries()) {
|
|
@@ -318,76 +676,102 @@ export async function synthesizeSsmlChunks(
|
|
|
318
676
|
durationMs: 0,
|
|
319
677
|
});
|
|
320
678
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
durationMs: 0,
|
|
331
|
-
});
|
|
332
|
-
const startedAt = Date.now();
|
|
333
|
-
try {
|
|
334
|
-
const result = await synthesizeSsml(input.ssml, {
|
|
335
|
-
...config,
|
|
336
|
-
...(input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {}),
|
|
337
|
-
...(input.sourceNodePath ? { sourceNodePath: input.sourceNodePath } : {}),
|
|
338
|
-
chunkIndex: index,
|
|
339
|
-
onProgress: undefined,
|
|
340
|
-
});
|
|
341
|
-
results.push(result);
|
|
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;
|
|
342
688
|
report({
|
|
343
|
-
currentChunk:
|
|
689
|
+
currentChunk: completed,
|
|
344
690
|
totalChunks,
|
|
345
|
-
percent: totalChunks === 0 ? 100 : Math.round((
|
|
691
|
+
percent: totalChunks === 0 ? 100 : Math.round((completed / totalChunks) * 100),
|
|
346
692
|
chunkIndex: index,
|
|
347
693
|
originalTextRange: input.originalTextRange,
|
|
348
|
-
status: "
|
|
349
|
-
durationMs:
|
|
694
|
+
status: "synthesizing",
|
|
695
|
+
durationMs: 0,
|
|
350
696
|
});
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
+
}
|
|
363
751
|
}
|
|
364
|
-
}
|
|
365
|
-
|
|
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,
|
|
758
|
+
});
|
|
366
759
|
}
|
|
367
760
|
|
|
368
761
|
/** Concatenates audio buffers and shifts all synchronization events by prior chunk durations. */
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
)
|
|
377
|
-
: new Uint8Array(results.reduce((total, result) => total + result.audioData.byteLength, 0));
|
|
378
|
-
if (!format) {
|
|
379
|
-
let offset = 0;
|
|
380
|
-
for (const result of results) {
|
|
381
|
-
audioData.set(new Uint8Array(result.audioData), offset);
|
|
382
|
-
offset += result.audioData.byteLength;
|
|
383
|
-
}
|
|
384
|
-
}
|
|
762
|
+
function createMergedResult(
|
|
763
|
+
results: readonly SsmlSynthesisResult[],
|
|
764
|
+
audioData: ArrayBuffer,
|
|
765
|
+
format: string,
|
|
766
|
+
audioSpec?: AudioSpecification,
|
|
767
|
+
outputMimeType?: string,
|
|
768
|
+
): MergedSynthesisResult {
|
|
385
769
|
const boundaries: NonNullable<SsmlSynthesisResult["boundaries"]> = [];
|
|
386
770
|
const visemes: NonNullable<SsmlSynthesisResult["visemes"]> = [];
|
|
387
771
|
const bookmarks: NonNullable<SsmlSynthesisResult["bookmarks"]> = [];
|
|
388
772
|
let durationOffset = 0;
|
|
389
773
|
|
|
390
|
-
for (const result of results) {
|
|
774
|
+
for (const [resultIndex, result] of results.entries()) {
|
|
391
775
|
const chunkBoundaries =
|
|
392
776
|
result.boundaries && result.boundaries.length > 0
|
|
393
777
|
? result.boundaries
|
|
@@ -400,11 +784,12 @@ export function mergeSynthesisResults(results: readonly SsmlSynthesisResult[], f
|
|
|
400
784
|
...boundary,
|
|
401
785
|
audioOffsetMs: boundary.audioOffsetMs + durationOffset,
|
|
402
786
|
chunkAudioOffsetMs: boundary.chunkAudioOffsetMs ?? boundary.audioOffsetMs,
|
|
403
|
-
...(boundary.chunkIndex === undefined ? { chunkIndex:
|
|
787
|
+
...(boundary.chunkIndex === undefined ? { chunkIndex: resultIndex } : {}),
|
|
404
788
|
...(boundary.sourceNodePath ? { sourceNodePath: [...boundary.sourceNodePath] } : {}),
|
|
405
789
|
...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
|
|
406
790
|
...(textRange ? { textRange: { ...textRange } } : {}),
|
|
407
791
|
...(requestId ? { requestId } : {}),
|
|
792
|
+
mappingStatus: boundary.mappingStatus ?? "unmapped",
|
|
408
793
|
});
|
|
409
794
|
}
|
|
410
795
|
for (const viseme of result.visemes ?? []) {
|
|
@@ -415,11 +800,12 @@ export function mergeSynthesisResults(results: readonly SsmlSynthesisResult[], f
|
|
|
415
800
|
...viseme,
|
|
416
801
|
audioOffsetMs: viseme.audioOffsetMs + durationOffset,
|
|
417
802
|
chunkAudioOffsetMs: viseme.chunkAudioOffsetMs ?? viseme.audioOffsetMs,
|
|
418
|
-
...(viseme.chunkIndex === undefined ? { chunkIndex:
|
|
803
|
+
...(viseme.chunkIndex === undefined ? { chunkIndex: resultIndex } : {}),
|
|
419
804
|
...(viseme.sourceNodePath ? { sourceNodePath: [...viseme.sourceNodePath] } : {}),
|
|
420
805
|
...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
|
|
421
806
|
...(textRange ? { textRange: { ...textRange } } : {}),
|
|
422
807
|
...(requestId ? { requestId } : {}),
|
|
808
|
+
mappingStatus: viseme.mappingStatus ?? "unmapped",
|
|
423
809
|
});
|
|
424
810
|
}
|
|
425
811
|
for (const bookmark of result.bookmarks ?? []) {
|
|
@@ -430,19 +816,23 @@ export function mergeSynthesisResults(results: readonly SsmlSynthesisResult[], f
|
|
|
430
816
|
...bookmark,
|
|
431
817
|
audioOffsetMs: bookmark.audioOffsetMs + durationOffset,
|
|
432
818
|
chunkAudioOffsetMs: bookmark.chunkAudioOffsetMs ?? bookmark.audioOffsetMs,
|
|
433
|
-
...(bookmark.chunkIndex === undefined ? { chunkIndex:
|
|
819
|
+
...(bookmark.chunkIndex === undefined ? { chunkIndex: resultIndex } : {}),
|
|
434
820
|
...(bookmark.sourceNodePath ? { sourceNodePath: [...bookmark.sourceNodePath] } : {}),
|
|
435
821
|
...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
|
|
436
822
|
...(textRange ? { textRange: { ...textRange } } : {}),
|
|
437
823
|
...(requestId ? { requestId } : {}),
|
|
824
|
+
mappingStatus: bookmark.mappingStatus ?? "unmapped",
|
|
438
825
|
});
|
|
439
826
|
}
|
|
440
827
|
durationOffset += Math.max(0, result.durationMs);
|
|
441
828
|
}
|
|
442
829
|
|
|
443
830
|
return {
|
|
444
|
-
audioData
|
|
831
|
+
audioData,
|
|
445
832
|
durationMs: durationOffset,
|
|
833
|
+
mimeType: resolveMimeType(format),
|
|
834
|
+
audioSpec: audioSpec ?? formatAudioSpecification(format),
|
|
835
|
+
...(outputMimeType ? { mimeType: outputMimeType } : {}),
|
|
446
836
|
...(boundaries.length > 0 ? { boundaries, wordBoundary: boundaries, wordBoundaries: boundaries } : {}),
|
|
447
837
|
...(visemes.length > 0 ? { visemes } : {}),
|
|
448
838
|
...(bookmarks.length > 0 ? { bookmarks } : {}),
|
|
@@ -451,6 +841,74 @@ export function mergeSynthesisResults(results: readonly SsmlSynthesisResult[], f
|
|
|
451
841
|
};
|
|
452
842
|
}
|
|
453
843
|
|
|
844
|
+
export function mergeSynthesisResults(
|
|
845
|
+
results: readonly SsmlSynthesisResult[],
|
|
846
|
+
options: AsyncMergeSynthesisOptions,
|
|
847
|
+
): Promise<MergedSynthesisResult>;
|
|
848
|
+
export function mergeSynthesisResults(
|
|
849
|
+
results: readonly SsmlSynthesisResult[],
|
|
850
|
+
options: MergeAudioOptions,
|
|
851
|
+
): MergedSynthesisResult;
|
|
852
|
+
export function mergeSynthesisResults(
|
|
853
|
+
results: readonly SsmlSynthesisResult[],
|
|
854
|
+
options: MergeSynthesisOptions | string,
|
|
855
|
+
): SsmlSynthesisResult | Promise<SsmlSynthesisResult> {
|
|
856
|
+
const resolvedOptions: MergeSynthesisOptions =
|
|
857
|
+
typeof options === "string" ? { format: options as AzureTtsOutputFormat } : options;
|
|
858
|
+
const format = resolvedOptions?.format;
|
|
859
|
+
if (!format) throw new UnsupportedMergeFormatError("");
|
|
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();
|
|
865
|
+
if (resolvedOptions.customMerger) {
|
|
866
|
+
return Promise.resolve()
|
|
867
|
+
.then(() =>
|
|
868
|
+
resolvedOptions.customMerger?.(buffers, {
|
|
869
|
+
format,
|
|
870
|
+
outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
|
|
871
|
+
inputSpecs,
|
|
872
|
+
signal,
|
|
873
|
+
}),
|
|
874
|
+
)
|
|
875
|
+
.then((merged) => {
|
|
876
|
+
if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
|
|
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
|
+
);
|
|
890
|
+
})
|
|
891
|
+
.catch((error: unknown) => {
|
|
892
|
+
if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
|
|
893
|
+
throw error;
|
|
894
|
+
throw new MergeError(`Custom audio merger failed for format "${format}".`, error);
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
try {
|
|
898
|
+
return createMergedResult(
|
|
899
|
+
results,
|
|
900
|
+
mergeAudioBuffers(buffers, { format }),
|
|
901
|
+
format,
|
|
902
|
+
inputSpecs[0],
|
|
903
|
+
resolvedOptions.outputMimeType,
|
|
904
|
+
);
|
|
905
|
+
} catch (error) {
|
|
906
|
+
if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
|
|
907
|
+
throw error;
|
|
908
|
+
throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
454
912
|
/** Backward-compatible audio-only synthesis helper. */
|
|
455
913
|
export async function synthesizeSpeech(ssml: string, config: TtsConfig): Promise<ArrayBuffer> {
|
|
456
914
|
return (await synthesizeSsml(ssml, config)).audioData;
|