@ssml-builder-js/azure-tts-client 2.15.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 +22 -0
- package/dist/index.d.mts +121 -7
- package/dist/index.d.ts +121 -7
- package/dist/index.js +786 -170
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +786 -171
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +17 -4
- package/src/errors.ts +66 -2
- package/src/index.ts +26 -2
- package/src/safe.ts +414 -118
- package/src/synthesis.ts +514 -69
- package/src/types.ts +89 -0
- package/src/voiceCatalog.ts +4 -0
- package/test/v216-pipeline.test.ts +110 -0
- package/test/v217-pipeline.test.ts +125 -0
package/src/synthesis.ts
CHANGED
|
@@ -1,30 +1,42 @@
|
|
|
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,
|
|
11
|
+
getRetryAfterDelayMs,
|
|
9
12
|
} from "./errors.ts";
|
|
10
|
-
import { resolveMimeType, type AzureTtsOutputFormat } from "./outputFormats.ts";
|
|
13
|
+
import { DEFAULT_OUTPUT_FORMAT, resolveMimeType, type AzureTtsOutputFormat } from "./outputFormats.ts";
|
|
11
14
|
import { createSpeechConfig } from "./speechConfig.ts";
|
|
12
15
|
import type {
|
|
13
16
|
MergedSynthesisResult,
|
|
14
17
|
SsmlSynthesisChunk,
|
|
15
18
|
SsmlSynthesisResult,
|
|
19
|
+
AudioSpecification,
|
|
16
20
|
SynthesisProgressEvent,
|
|
17
21
|
TtsConfig,
|
|
22
|
+
RetryOptions,
|
|
23
|
+
CustomAudioMerger,
|
|
24
|
+
PostMergeValidator,
|
|
18
25
|
} from "./types.ts";
|
|
19
26
|
|
|
20
27
|
export type MergeAudioFormat = "wav" | "mp3" | "raw";
|
|
21
28
|
|
|
22
29
|
export interface MergeAudioOptions {
|
|
23
30
|
format: AzureTtsOutputFormat;
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
outputMimeType?: string;
|
|
24
33
|
}
|
|
25
34
|
|
|
35
|
+
export type InputAudioSpecs = AudioSpecification[];
|
|
36
|
+
|
|
26
37
|
export interface MergeSynthesisOptions extends MergeAudioOptions {
|
|
27
|
-
customMerger?:
|
|
38
|
+
customMerger?: CustomAudioMerger;
|
|
39
|
+
postMergeValidator?: PostMergeValidator;
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
type AsyncMergeSynthesisOptions = MergeSynthesisOptions & {
|
|
@@ -84,6 +96,150 @@ function parseWav(buffer: ArrayBuffer): ParsedWav {
|
|
|
84
96
|
return { chunks, data, format };
|
|
85
97
|
}
|
|
86
98
|
|
|
99
|
+
function formatNumber(format: string, pattern: RegExp, fallback: number): number {
|
|
100
|
+
const match = pattern.exec(format);
|
|
101
|
+
return match?.[1] ? Number(match[1]) : fallback;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatChannels(format: string, fallback: number): number {
|
|
105
|
+
if (/stereo|2ch|dual/i.test(format)) return 2;
|
|
106
|
+
if (/mono|1ch/i.test(format)) return 1;
|
|
107
|
+
return fallback;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function formatAudioSpecification(format: string): AudioSpecification {
|
|
111
|
+
const sampleRate = formatNumber(format, /(\d+)(?:khz|kHz|hz|Hz)/, 0);
|
|
112
|
+
const channels = formatChannels(format, 0);
|
|
113
|
+
const bitrateMatch = /(\d+)(?:kbitrate|kbps|kbits?)/i.exec(format);
|
|
114
|
+
const bitrate = bitrateMatch?.[1] ? Number(bitrateMatch[1]) * 1000 : undefined;
|
|
115
|
+
const codec: AudioSpecification["codec"] = /mp3|mpeg/i.test(format)
|
|
116
|
+
? "mp3"
|
|
117
|
+
: /opus/i.test(format)
|
|
118
|
+
? "opus"
|
|
119
|
+
: /silk/i.test(format)
|
|
120
|
+
? "silk"
|
|
121
|
+
: /pcm|mulaw|alaw|siren/i.test(format)
|
|
122
|
+
? "pcm"
|
|
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;
|
|
136
|
+
return {
|
|
137
|
+
format,
|
|
138
|
+
mimeType: resolveMimeType(format),
|
|
139
|
+
codec,
|
|
140
|
+
sampleRate,
|
|
141
|
+
channels,
|
|
142
|
+
...(bitrate ? { bitrate } : {}),
|
|
143
|
+
...(bitDepthMatch?.[1] ? { bitDepth: Number(bitDepthMatch[1]) } : {}),
|
|
144
|
+
...(container ? { container } : {}),
|
|
145
|
+
isVbr: /vbr/i.test(format),
|
|
146
|
+
isCompressed: codec === "mp3" || codec === "opus" || codec === "silk",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseMp3Specification(buffer: ArrayBuffer, format: string): AudioSpecification | undefined {
|
|
151
|
+
const bytes = stripMp3Tags(buffer);
|
|
152
|
+
const bitrates = [
|
|
153
|
+
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
154
|
+
[0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0],
|
|
155
|
+
[0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0],
|
|
156
|
+
];
|
|
157
|
+
const sampleRates = [
|
|
158
|
+
[44_100, 48_000, 32_000],
|
|
159
|
+
[22_050, 24_000, 16_000],
|
|
160
|
+
[11_025, 12_000, 8_000],
|
|
161
|
+
];
|
|
162
|
+
for (let index = 0; index + 4 <= bytes.length; index += 1) {
|
|
163
|
+
if (bytes[index] !== 0xff || (bytes[index + 1] ?? 0) < 0xe0) continue;
|
|
164
|
+
const header = bytes[index + 1] ?? 0;
|
|
165
|
+
const versionBits = (header >> 3) & 0x03;
|
|
166
|
+
const layer = (header >> 1) & 0x03;
|
|
167
|
+
const bitrateIndex = (bytes[index + 2] ?? 0) >> 4;
|
|
168
|
+
const sampleIndex = ((bytes[index + 2] ?? 0) >> 2) & 0x03;
|
|
169
|
+
if (versionBits === 1 || layer !== 1 || bitrateIndex === 0 || bitrateIndex === 15 || sampleIndex === 3) continue;
|
|
170
|
+
const versionIndex = versionBits === 3 ? 0 : versionBits === 2 ? 1 : 2;
|
|
171
|
+
const bitrateTable = versionBits === 3 ? bitrates[1] : bitrates[2];
|
|
172
|
+
const sampleRate = sampleRates[versionIndex]?.[sampleIndex] ?? 0;
|
|
173
|
+
const bitrateKbps = bitrateTable?.[bitrateIndex] ?? 0;
|
|
174
|
+
if (!sampleRate || !bitrateKbps) continue;
|
|
175
|
+
return {
|
|
176
|
+
format,
|
|
177
|
+
mimeType: "audio/mpeg",
|
|
178
|
+
codec: "mp3",
|
|
179
|
+
sampleRate,
|
|
180
|
+
channels: (bytes[index + 3] ?? 0) >> 6 === 3 ? 1 : 2,
|
|
181
|
+
bitrate: bitrateKbps * 1000,
|
|
182
|
+
container: "mp3-raw",
|
|
183
|
+
isVbr: false,
|
|
184
|
+
isCompressed: true,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Extracts the stream specification from a WAV/MP3 header and output-format fallback. */
|
|
191
|
+
export function inspectAudioSpecification(buffer: ArrayBuffer, format: string): AudioSpecification {
|
|
192
|
+
if (isWavFormat(format) || ascii(new Uint8Array(buffer), 0, "RIFF")) {
|
|
193
|
+
const parsed = parseWav(buffer);
|
|
194
|
+
if (parsed.format.byteLength < 16) throw new Error("Invalid WAV fmt chunk.");
|
|
195
|
+
const view = new DataView(parsed.format.buffer, parsed.format.byteOffset, parsed.format.byteLength);
|
|
196
|
+
const sampleRate = view.getUint32(4, true);
|
|
197
|
+
const channels = view.getUint16(2, true);
|
|
198
|
+
const bitsPerSample = parsed.format.byteLength >= 16 ? view.getUint16(14, true) : 0;
|
|
199
|
+
const formatCode = view.getUint16(0, true);
|
|
200
|
+
return {
|
|
201
|
+
format,
|
|
202
|
+
mimeType: "audio/wav",
|
|
203
|
+
codec: formatCode === 1 ? "pcm" : "unknown",
|
|
204
|
+
sampleRate,
|
|
205
|
+
channels,
|
|
206
|
+
...(sampleRate && channels && bitsPerSample ? { bitrate: sampleRate * channels * bitsPerSample } : {}),
|
|
207
|
+
bitDepth: bitsPerSample,
|
|
208
|
+
container: "riff-wave",
|
|
209
|
+
isVbr: false,
|
|
210
|
+
isCompressed: formatCode !== 1,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (isMp3Format(format)) return parseMp3Specification(buffer, format) ?? formatAudioSpecification(format);
|
|
214
|
+
return formatAudioSpecification(format);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function validateAudioSpecifications(specs: readonly AudioSpecification[]): void {
|
|
218
|
+
const first = specs[0];
|
|
219
|
+
if (!first) return;
|
|
220
|
+
const mismatch = specs.find(
|
|
221
|
+
(spec) =>
|
|
222
|
+
spec.sampleRate !== first.sampleRate ||
|
|
223
|
+
spec.channels !== first.channels ||
|
|
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),
|
|
228
|
+
);
|
|
229
|
+
if (mismatch)
|
|
230
|
+
throw new AudioFormatMismatchError(
|
|
231
|
+
`Audio chunks have incompatible specifications: ${first.sampleRate}Hz/${first.channels}ch versus ${mismatch.sampleRate}Hz/${mismatch.channels}ch.`,
|
|
232
|
+
specs,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isAudioFormatMismatch(error: unknown): error is AudioFormatMismatchError {
|
|
237
|
+
return (
|
|
238
|
+
error instanceof AudioFormatMismatchError ||
|
|
239
|
+
(error !== null && typeof error === "object" && "kind" in error && error.kind === "audio-format-mismatch")
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
87
243
|
function writeUint32(target: Uint8Array, offset: number, value: number): void {
|
|
88
244
|
new DataView(target.buffer).setUint32(offset, value, true);
|
|
89
245
|
}
|
|
@@ -185,6 +341,7 @@ export function mergeAudioBuffers(buffers: readonly ArrayBuffer[], options: Merg
|
|
|
185
341
|
const format = typeof options === "string" ? options : options?.format;
|
|
186
342
|
if (!format) throw new UnsupportedMergeFormatError("");
|
|
187
343
|
try {
|
|
344
|
+
validateAudioSpecifications(buffers.map((buffer) => inspectAudioSpecification(buffer, format)));
|
|
188
345
|
if (isWavFormat(format)) return mergeWavBuffers(buffers);
|
|
189
346
|
if (isMp3Format(format)) {
|
|
190
347
|
const parts = buffers.map(stripMp3Tags);
|
|
@@ -207,7 +364,8 @@ export function mergeAudioBuffers(buffers: readonly ArrayBuffer[], options: Merg
|
|
|
207
364
|
}
|
|
208
365
|
throw new UnsupportedMergeFormatError(format);
|
|
209
366
|
} catch (error) {
|
|
210
|
-
if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError)
|
|
367
|
+
if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
|
|
368
|
+
throw error;
|
|
211
369
|
throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
|
|
212
370
|
}
|
|
213
371
|
}
|
|
@@ -298,6 +456,7 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
298
456
|
textRange?: { start: number; end: number };
|
|
299
457
|
originalTextRange?: { start: number; end: number };
|
|
300
458
|
sourceNodePath?: string[];
|
|
459
|
+
mappingStatus: "exact" | "fallback" | "unmapped";
|
|
301
460
|
} => {
|
|
302
461
|
const marker = markerName ? sourceMarkers.find((candidate) => candidate.name === markerName) : undefined;
|
|
303
462
|
if (marker) {
|
|
@@ -305,16 +464,23 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
305
464
|
originalTextRange: { ...marker.originalTextRange },
|
|
306
465
|
sourceNodePath: [...marker.sourceNodePath],
|
|
307
466
|
textRange: { ...marker.originalTextRange },
|
|
467
|
+
mappingStatus: "exact",
|
|
308
468
|
};
|
|
309
469
|
}
|
|
310
|
-
if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath)
|
|
470
|
+
if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) {
|
|
471
|
+
return { mappingStatus: "unmapped" };
|
|
472
|
+
}
|
|
311
473
|
const value = text ?? "";
|
|
312
474
|
let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? (offsetHint as number) : -1;
|
|
313
|
-
|
|
475
|
+
let mappingStatus: "exact" | "fallback" | "unmapped" = "exact";
|
|
476
|
+
if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value) {
|
|
314
477
|
localStart = -1;
|
|
478
|
+
mappingStatus = "fallback";
|
|
479
|
+
}
|
|
315
480
|
if (localStart < 0 || localStart > sourceText.length) {
|
|
316
481
|
localStart = value ? sourceText.indexOf(value, sourceEventCursor) : sourceEventCursor;
|
|
317
482
|
if (localStart < 0) localStart = value ? sourceText.indexOf(value) : sourceEventCursor;
|
|
483
|
+
mappingStatus = "fallback";
|
|
318
484
|
}
|
|
319
485
|
localStart = Math.max(0, localStart);
|
|
320
486
|
const localEnd = Math.min(sourceText.length, localStart + value.length);
|
|
@@ -335,6 +501,7 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
335
501
|
: config.sourceNodePath
|
|
336
502
|
? { sourceNodePath: [...config.sourceNodePath] }
|
|
337
503
|
: {}),
|
|
504
|
+
mappingStatus: segment || config.sourceTextRange || config.sourceNodePath ? mappingStatus : "unmapped",
|
|
338
505
|
};
|
|
339
506
|
};
|
|
340
507
|
synthesizer.wordBoundary = (_sender, event) => {
|
|
@@ -384,22 +551,36 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
384
551
|
);
|
|
385
552
|
const durationMs = result.audioDuration ? ticksToMilliseconds(result.audioDuration) : eventDurationMs;
|
|
386
553
|
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
|
-
|
|
554
|
+
const addSourceMetadata = <T extends { audioOffsetMs: number; mappingStatus: "exact" | "fallback" | "unmapped" }>(
|
|
555
|
+
event: T,
|
|
556
|
+
): T => {
|
|
557
|
+
const mapped = {
|
|
558
|
+
...event,
|
|
559
|
+
...(config.sourceTextRange && !("textRange" in event) ? { textRange: { ...config.sourceTextRange } } : {}),
|
|
560
|
+
...(config.sourceTextRange && !("originalTextRange" in event)
|
|
561
|
+
? { originalTextRange: { ...config.sourceTextRange } }
|
|
562
|
+
: {}),
|
|
563
|
+
...(config.chunkIndex !== undefined ? { chunkIndex: config.chunkIndex } : {}),
|
|
564
|
+
...(config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {}),
|
|
565
|
+
...(requestId ? { requestId } : {}),
|
|
566
|
+
} as T;
|
|
567
|
+
if (event.mappingStatus === "unmapped") {
|
|
568
|
+
Object.defineProperty(mapped, "mappingStatus", { value: "unmapped", enumerable: false });
|
|
569
|
+
Object.defineProperty(mapped, "toJSON", {
|
|
570
|
+
value: () => ({ ...mapped, mappingStatus: "unmapped" }),
|
|
571
|
+
enumerable: false,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
return mapped;
|
|
575
|
+
};
|
|
397
576
|
const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
|
|
398
577
|
const sourceVisemes = visemes.map((viseme) => addSourceMetadata(viseme));
|
|
399
578
|
const sourceBookmarks = bookmarks.map((bookmark) => addSourceMetadata(bookmark));
|
|
400
579
|
resolve({
|
|
401
580
|
audioData: result.audioData,
|
|
402
581
|
durationMs,
|
|
582
|
+
audioSpec: inspectAudioSpecification(result.audioData, config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
|
|
583
|
+
mimeType: resolveMimeType(config.outputFormat ?? DEFAULT_OUTPUT_FORMAT),
|
|
403
584
|
...(config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {}),
|
|
404
585
|
...(requestId ? { requestId } : {}),
|
|
405
586
|
...(sourceBoundaries.length > 0
|
|
@@ -415,10 +596,11 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
415
596
|
abortHandler = () => rejectWithError(new SynthesisCancelledError());
|
|
416
597
|
config.signal.addEventListener("abort", abortHandler, { once: true });
|
|
417
598
|
}
|
|
418
|
-
|
|
599
|
+
const timeoutMs = config.timeouts?.perChunkMs ?? config.timeoutMs;
|
|
600
|
+
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
419
601
|
timeout = setTimeout(
|
|
420
|
-
() => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${
|
|
421
|
-
|
|
602
|
+
() => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${timeoutMs} ms.`)),
|
|
603
|
+
timeoutMs,
|
|
422
604
|
);
|
|
423
605
|
}
|
|
424
606
|
synthesizer.speakSsmlAsync(ssml, cb, rejectWithError);
|
|
@@ -428,13 +610,150 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
428
610
|
});
|
|
429
611
|
}
|
|
430
612
|
|
|
431
|
-
|
|
613
|
+
function isRetryableSynthesisError(error: unknown): boolean {
|
|
614
|
+
if (error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError) return false;
|
|
615
|
+
if (error instanceof AzureTtsError && error.status !== 0)
|
|
616
|
+
return error.status === 429 || (error.status >= 500 && error.status < 600);
|
|
617
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
618
|
+
if (/\b4\d{2}\b/.test(message)) return false;
|
|
619
|
+
const status = error && typeof error === "object" && "status" in error ? error.status : undefined;
|
|
620
|
+
if (typeof status === "number") return status === 429 || (status >= 500 && status < 600);
|
|
621
|
+
return /network|connection|connect|socket|fetch failed|econn|etimedout|temporar|transient|unavailable/i.test(message);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function retryDelay(options: RetryOptions, retryAttempt: number, error?: unknown): number {
|
|
625
|
+
const retryAfterMs = getRetryAfterDelayMs(error);
|
|
626
|
+
if (retryAfterMs !== undefined) return retryAfterMs;
|
|
627
|
+
const base = Math.min(options.maxDelayMs, options.initialDelayMs * 2 ** Math.max(0, retryAttempt - 1));
|
|
628
|
+
return Math.floor(Math.random() * (base + 1));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function resolveConcurrency(value: number | undefined, total: number): number {
|
|
632
|
+
if (value === undefined) return 1;
|
|
633
|
+
if (value === Infinity) return Math.max(1, total);
|
|
634
|
+
return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function waitForRetry(delayMs: number, signal?: AbortSignal): Promise<void> {
|
|
638
|
+
if (signal?.aborted) throw new SynthesisCancelledError();
|
|
639
|
+
if (delayMs <= 0) return;
|
|
640
|
+
await new Promise<void>((resolve, reject) => {
|
|
641
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
642
|
+
const abort = () => {
|
|
643
|
+
clearTimeout(timer);
|
|
644
|
+
signal?.removeEventListener("abort", abort);
|
|
645
|
+
reject(new SynthesisCancelledError());
|
|
646
|
+
};
|
|
647
|
+
timer = setTimeout(() => {
|
|
648
|
+
signal?.removeEventListener("abort", abort);
|
|
649
|
+
resolve();
|
|
650
|
+
}, delayMs);
|
|
651
|
+
if (signal) {
|
|
652
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function synthesizeWithRetry(
|
|
658
|
+
ssml: string,
|
|
659
|
+
config: TtsConfig,
|
|
660
|
+
retryOptions: RetryOptions | undefined,
|
|
661
|
+
onRetry: (retryAttempt: number, nextRetryDelayMs: number) => void,
|
|
662
|
+
): Promise<SsmlSynthesisResult> {
|
|
663
|
+
const options = retryOptions
|
|
664
|
+
? {
|
|
665
|
+
maxRetries: Math.max(0, Math.floor(retryOptions.maxRetries)),
|
|
666
|
+
initialDelayMs: Math.max(0, retryOptions.initialDelayMs),
|
|
667
|
+
maxDelayMs: Math.max(0, retryOptions.maxDelayMs),
|
|
668
|
+
shouldRetry: retryOptions.shouldRetry,
|
|
669
|
+
}
|
|
670
|
+
: undefined;
|
|
671
|
+
let attempt = 0;
|
|
672
|
+
while (true) {
|
|
673
|
+
if (config.signal?.aborted) throw new SynthesisCancelledError();
|
|
674
|
+
try {
|
|
675
|
+
return await synthesizeSsml(ssml, config);
|
|
676
|
+
} catch (error) {
|
|
677
|
+
if (
|
|
678
|
+
!options ||
|
|
679
|
+
attempt >= options.maxRetries ||
|
|
680
|
+
!(options.shouldRetry?.(error, attempt + 1) ?? isRetryableSynthesisError(error))
|
|
681
|
+
)
|
|
682
|
+
throw error;
|
|
683
|
+
attempt += 1;
|
|
684
|
+
const delayMs = retryDelay(options, attempt, error);
|
|
685
|
+
onRetry(attempt, delayMs);
|
|
686
|
+
await waitForRetry(delayMs, config.signal);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
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
|
+
|
|
740
|
+
/** Synthesizes chunks with bounded concurrency, retries transient failures, and merges in chunk order. */
|
|
432
741
|
export async function synthesizeSsmlChunks(
|
|
433
742
|
chunks: readonly (SsmlSynthesisChunk | string)[],
|
|
434
743
|
config: TtsConfig,
|
|
435
744
|
): Promise<SsmlSynthesisResult> {
|
|
436
|
-
const results: SsmlSynthesisResult
|
|
745
|
+
const results: Array<SsmlSynthesisResult | undefined> = new Array(chunks.length);
|
|
437
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);
|
|
438
757
|
const report = (event: SynthesisProgressEvent): void => config.onProgress?.(event);
|
|
439
758
|
for (const [index, chunk] of chunks.entries()) {
|
|
440
759
|
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
@@ -448,57 +767,115 @@ export async function synthesizeSsmlChunks(
|
|
|
448
767
|
durationMs: 0,
|
|
449
768
|
});
|
|
450
769
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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);
|
|
770
|
+
let completed = [...results].filter((result) => result !== undefined).length;
|
|
771
|
+
let nextIndex = 0;
|
|
772
|
+
const concurrency = resolveConcurrency(config.concurrency, chunks.length);
|
|
773
|
+
let firstError: unknown;
|
|
774
|
+
const failedIndices = new Set<number>();
|
|
775
|
+
const worker = async (): Promise<void> => {
|
|
776
|
+
while (true) {
|
|
777
|
+
const index = nextIndex++;
|
|
778
|
+
if (index >= chunks.length) return;
|
|
779
|
+
if (!shouldSynthesize(index)) continue;
|
|
780
|
+
if (firstError && config.cancelOnFailure !== false) return;
|
|
781
|
+
const chunk = chunks[index];
|
|
782
|
+
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
476
783
|
report({
|
|
477
|
-
currentChunk:
|
|
784
|
+
currentChunk: completed,
|
|
478
785
|
totalChunks,
|
|
479
|
-
percent: totalChunks === 0 ? 100 : Math.round((
|
|
786
|
+
percent: totalChunks === 0 ? 100 : Math.round((completed / totalChunks) * 100),
|
|
480
787
|
chunkIndex: index,
|
|
481
788
|
originalTextRange: input.originalTextRange,
|
|
482
|
-
status: "
|
|
483
|
-
durationMs:
|
|
789
|
+
status: "synthesizing",
|
|
790
|
+
durationMs: 0,
|
|
484
791
|
});
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
792
|
+
const startedAt = Date.now();
|
|
793
|
+
try {
|
|
794
|
+
const result = await synthesizeChunkWithTimeout(
|
|
795
|
+
input.ssml,
|
|
796
|
+
{
|
|
797
|
+
...config,
|
|
798
|
+
signal: scope.signal,
|
|
799
|
+
...(input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {}),
|
|
800
|
+
...((input.sourceNodePath ?? config.sourceNodePath)
|
|
801
|
+
? { sourceNodePath: [...(input.sourceNodePath ?? config.sourceNodePath ?? [])] }
|
|
802
|
+
: {}),
|
|
803
|
+
...(input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {}),
|
|
804
|
+
...(input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {}),
|
|
805
|
+
chunkIndex: index,
|
|
806
|
+
onProgress: undefined,
|
|
807
|
+
},
|
|
808
|
+
config.retryOptions,
|
|
809
|
+
config.timeouts?.chunkWithRetriesMs ?? config.timeouts?.perChunkMs ?? config.timeoutMs,
|
|
810
|
+
(retryAttempt, nextRetryDelayMs) =>
|
|
811
|
+
report({
|
|
812
|
+
currentChunk: completed,
|
|
813
|
+
totalChunks,
|
|
814
|
+
percent: totalChunks === 0 ? 100 : Math.round((completed / totalChunks) * 100),
|
|
815
|
+
chunkIndex: index,
|
|
816
|
+
originalTextRange: input.originalTextRange,
|
|
817
|
+
status: "synthesizing",
|
|
818
|
+
durationMs: Date.now() - startedAt,
|
|
819
|
+
retryAttempt,
|
|
820
|
+
nextRetryDelayMs,
|
|
821
|
+
isRetrying: true,
|
|
822
|
+
}),
|
|
823
|
+
);
|
|
824
|
+
results[index] = result;
|
|
825
|
+
completed += 1;
|
|
826
|
+
report({
|
|
827
|
+
currentChunk: completed,
|
|
828
|
+
totalChunks,
|
|
829
|
+
percent: totalChunks === 0 ? 100 : Math.round((completed / totalChunks) * 100),
|
|
830
|
+
chunkIndex: index,
|
|
831
|
+
originalTextRange: input.originalTextRange,
|
|
832
|
+
status: "success",
|
|
833
|
+
durationMs: Date.now() - startedAt,
|
|
834
|
+
});
|
|
835
|
+
} catch (error) {
|
|
836
|
+
failedIndices.add(index);
|
|
837
|
+
report({
|
|
838
|
+
currentChunk: completed,
|
|
839
|
+
totalChunks,
|
|
840
|
+
percent: totalChunks === 0 ? 100 : Math.round((completed / totalChunks) * 100),
|
|
841
|
+
chunkIndex: index,
|
|
842
|
+
originalTextRange: input.originalTextRange,
|
|
843
|
+
status: "failed",
|
|
844
|
+
durationMs: Date.now() - startedAt,
|
|
845
|
+
error,
|
|
846
|
+
});
|
|
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;
|
|
852
|
+
}
|
|
497
853
|
}
|
|
854
|
+
};
|
|
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();
|
|
498
878
|
}
|
|
499
|
-
return mergeSynthesisResults(results, {
|
|
500
|
-
format: (config.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3") as AzureTtsOutputFormat,
|
|
501
|
-
});
|
|
502
879
|
}
|
|
503
880
|
|
|
504
881
|
/** Concatenates audio buffers and shifts all synchronization events by prior chunk durations. */
|
|
@@ -506,6 +883,8 @@ function createMergedResult(
|
|
|
506
883
|
results: readonly SsmlSynthesisResult[],
|
|
507
884
|
audioData: ArrayBuffer,
|
|
508
885
|
format: string,
|
|
886
|
+
audioSpec?: AudioSpecification,
|
|
887
|
+
outputMimeType?: string,
|
|
509
888
|
): MergedSynthesisResult {
|
|
510
889
|
const boundaries: NonNullable<SsmlSynthesisResult["boundaries"]> = [];
|
|
511
890
|
const visemes: NonNullable<SsmlSynthesisResult["visemes"]> = [];
|
|
@@ -530,6 +909,7 @@ function createMergedResult(
|
|
|
530
909
|
...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
|
|
531
910
|
...(textRange ? { textRange: { ...textRange } } : {}),
|
|
532
911
|
...(requestId ? { requestId } : {}),
|
|
912
|
+
mappingStatus: boundary.mappingStatus ?? "unmapped",
|
|
533
913
|
});
|
|
534
914
|
}
|
|
535
915
|
for (const viseme of result.visemes ?? []) {
|
|
@@ -545,6 +925,7 @@ function createMergedResult(
|
|
|
545
925
|
...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
|
|
546
926
|
...(textRange ? { textRange: { ...textRange } } : {}),
|
|
547
927
|
...(requestId ? { requestId } : {}),
|
|
928
|
+
mappingStatus: viseme.mappingStatus ?? "unmapped",
|
|
548
929
|
});
|
|
549
930
|
}
|
|
550
931
|
for (const bookmark of result.bookmarks ?? []) {
|
|
@@ -560,6 +941,7 @@ function createMergedResult(
|
|
|
560
941
|
...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
|
|
561
942
|
...(textRange ? { textRange: { ...textRange } } : {}),
|
|
562
943
|
...(requestId ? { requestId } : {}),
|
|
944
|
+
mappingStatus: bookmark.mappingStatus ?? "unmapped",
|
|
563
945
|
});
|
|
564
946
|
}
|
|
565
947
|
durationOffset += Math.max(0, result.durationMs);
|
|
@@ -569,6 +951,8 @@ function createMergedResult(
|
|
|
569
951
|
audioData,
|
|
570
952
|
durationMs: durationOffset,
|
|
571
953
|
mimeType: resolveMimeType(format),
|
|
954
|
+
audioSpec: audioSpec ?? formatAudioSpecification(format),
|
|
955
|
+
...(outputMimeType ? { mimeType: outputMimeType } : {}),
|
|
572
956
|
...(boundaries.length > 0 ? { boundaries, wordBoundary: boundaries, wordBoundaries: boundaries } : {}),
|
|
573
957
|
...(visemes.length > 0 ? { visemes } : {}),
|
|
574
958
|
...(bookmarks.length > 0 ? { bookmarks } : {}),
|
|
@@ -581,6 +965,10 @@ export function mergeSynthesisResults(
|
|
|
581
965
|
results: readonly SsmlSynthesisResult[],
|
|
582
966
|
options: AsyncMergeSynthesisOptions,
|
|
583
967
|
): Promise<MergedSynthesisResult>;
|
|
968
|
+
export function mergeSynthesisResults(
|
|
969
|
+
results: readonly SsmlSynthesisResult[],
|
|
970
|
+
options: MergeSynthesisOptions,
|
|
971
|
+
): MergedSynthesisResult | Promise<MergedSynthesisResult>;
|
|
584
972
|
export function mergeSynthesisResults(
|
|
585
973
|
results: readonly SsmlSynthesisResult[],
|
|
586
974
|
options: MergeAudioOptions,
|
|
@@ -594,22 +982,79 @@ export function mergeSynthesisResults(
|
|
|
594
982
|
const format = resolvedOptions?.format;
|
|
595
983
|
if (!format) throw new UnsupportedMergeFormatError("");
|
|
596
984
|
const buffers = results.map((result) => result.audioData);
|
|
985
|
+
const inputSpecs = results.map((result) => result.audioSpec ?? inspectAudioSpecification(result.audioData, format));
|
|
986
|
+
validateAudioSpecifications(inputSpecs);
|
|
987
|
+
const signal = resolvedOptions.signal ?? new AbortController().signal;
|
|
988
|
+
if (signal.aborted) throw new SynthesisCancelledError();
|
|
597
989
|
if (resolvedOptions.customMerger) {
|
|
598
990
|
return Promise.resolve()
|
|
599
|
-
.then(() =>
|
|
991
|
+
.then(() =>
|
|
992
|
+
resolvedOptions.customMerger?.(buffers, {
|
|
993
|
+
format,
|
|
994
|
+
outputMimeType: resolvedOptions.outputMimeType ?? resolveMimeType(format),
|
|
995
|
+
inputSpecs,
|
|
996
|
+
signal,
|
|
997
|
+
}),
|
|
998
|
+
)
|
|
600
999
|
.then((merged) => {
|
|
601
1000
|
if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
|
|
602
|
-
|
|
1001
|
+
if (
|
|
1002
|
+
!(merged instanceof ArrayBuffer) ||
|
|
1003
|
+
(buffers.some((buffer) => buffer.byteLength > 0) && merged.byteLength === 0)
|
|
1004
|
+
)
|
|
1005
|
+
throw new MergeError("The custom audio merger returned an invalid audio buffer.");
|
|
1006
|
+
if (signal.aborted) throw new SynthesisCancelledError();
|
|
1007
|
+
const result = createMergedResult(
|
|
1008
|
+
results,
|
|
1009
|
+
merged,
|
|
1010
|
+
format,
|
|
1011
|
+
inspectAudioSpecification(merged, format),
|
|
1012
|
+
resolvedOptions.outputMimeType,
|
|
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
|
+
});
|
|
603
1025
|
})
|
|
604
1026
|
.catch((error: unknown) => {
|
|
605
|
-
if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError)
|
|
1027
|
+
if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
|
|
1028
|
+
throw error;
|
|
606
1029
|
throw new MergeError(`Custom audio merger failed for format "${format}".`, error);
|
|
607
1030
|
});
|
|
608
1031
|
}
|
|
609
1032
|
try {
|
|
610
|
-
|
|
1033
|
+
const result = createMergedResult(
|
|
1034
|
+
results,
|
|
1035
|
+
mergeAudioBuffers(buffers, { format }),
|
|
1036
|
+
format,
|
|
1037
|
+
inputSpecs[0],
|
|
1038
|
+
resolvedOptions.outputMimeType,
|
|
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;
|
|
611
1055
|
} catch (error) {
|
|
612
|
-
if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError)
|
|
1056
|
+
if (error instanceof UnsupportedMergeFormatError || isAudioFormatMismatch(error) || error instanceof MergeError)
|
|
1057
|
+
throw error;
|
|
613
1058
|
throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
|
|
614
1059
|
}
|
|
615
1060
|
}
|