@ssml-builder-js/azure-tts-client 2.14.0 → 2.15.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 +113 -17
- package/dist/index.d.ts +113 -17
- package/dist/index.js +310 -86
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +303 -84
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +19 -5
- package/src/errors.ts +63 -0
- package/src/index.ts +14 -2
- package/src/outputFormats.ts +14 -3
- package/src/safe.ts +160 -38
- package/src/synthesis.ts +215 -52
- package/src/types.ts +17 -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/src/synthesis.ts
CHANGED
|
@@ -1,10 +1,36 @@
|
|
|
1
1
|
import * as SpeechSDK from "microsoft-cognitiveservices-speech-sdk";
|
|
2
|
-
import {
|
|
2
|
+
import { getSsmlSourceMap } from "@ssml-builder-js/ssml-core";
|
|
3
|
+
import {
|
|
4
|
+
MergeError,
|
|
5
|
+
SynthesisCancelledError,
|
|
6
|
+
SynthesisTimeoutError,
|
|
7
|
+
toSynthesisError,
|
|
8
|
+
UnsupportedMergeFormatError,
|
|
9
|
+
} from "./errors.ts";
|
|
10
|
+
import { resolveMimeType, type AzureTtsOutputFormat } from "./outputFormats.ts";
|
|
3
11
|
import { createSpeechConfig } from "./speechConfig.ts";
|
|
4
|
-
import type {
|
|
12
|
+
import type {
|
|
13
|
+
MergedSynthesisResult,
|
|
14
|
+
SsmlSynthesisChunk,
|
|
15
|
+
SsmlSynthesisResult,
|
|
16
|
+
SynthesisProgressEvent,
|
|
17
|
+
TtsConfig,
|
|
18
|
+
} from "./types.ts";
|
|
5
19
|
|
|
6
20
|
export type MergeAudioFormat = "wav" | "mp3" | "raw";
|
|
7
21
|
|
|
22
|
+
export interface MergeAudioOptions {
|
|
23
|
+
format: AzureTtsOutputFormat;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MergeSynthesisOptions extends MergeAudioOptions {
|
|
27
|
+
customMerger?: (buffers: ArrayBuffer[], format: string) => Promise<ArrayBuffer> | ArrayBuffer;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type AsyncMergeSynthesisOptions = MergeSynthesisOptions & {
|
|
31
|
+
customMerger: NonNullable<MergeSynthesisOptions["customMerger"]>;
|
|
32
|
+
};
|
|
33
|
+
|
|
8
34
|
function ascii(bytes: Uint8Array, offset: number, value: string): boolean {
|
|
9
35
|
return [...value].every((character, index) => bytes[offset + index] === character.charCodeAt(0));
|
|
10
36
|
}
|
|
@@ -154,28 +180,36 @@ export function canMergeAudioFormat(format: string): boolean {
|
|
|
154
180
|
}
|
|
155
181
|
|
|
156
182
|
/** 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
|
-
|
|
183
|
+
export function mergeAudioBuffers(buffers: readonly ArrayBuffer[], options: MergeAudioOptions): ArrayBuffer;
|
|
184
|
+
export function mergeAudioBuffers(buffers: readonly ArrayBuffer[], options: MergeAudioOptions | string): ArrayBuffer {
|
|
185
|
+
const format = typeof options === "string" ? options : options?.format;
|
|
186
|
+
if (!format) throw new UnsupportedMergeFormatError("");
|
|
187
|
+
try {
|
|
188
|
+
if (isWavFormat(format)) return mergeWavBuffers(buffers);
|
|
189
|
+
if (isMp3Format(format)) {
|
|
190
|
+
const parts = buffers.map(stripMp3Tags);
|
|
191
|
+
const output = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0));
|
|
192
|
+
let offset = 0;
|
|
193
|
+
for (const part of parts) {
|
|
194
|
+
output.set(part, offset);
|
|
195
|
+
offset += part.byteLength;
|
|
196
|
+
}
|
|
197
|
+
return output.buffer;
|
|
166
198
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
199
|
+
if (isRawFormat(format)) {
|
|
200
|
+
const output = new Uint8Array(buffers.reduce((total, buffer) => total + buffer.byteLength, 0));
|
|
201
|
+
let offset = 0;
|
|
202
|
+
for (const buffer of buffers) {
|
|
203
|
+
output.set(new Uint8Array(buffer), offset);
|
|
204
|
+
offset += buffer.byteLength;
|
|
205
|
+
}
|
|
206
|
+
return output.buffer;
|
|
175
207
|
}
|
|
176
|
-
|
|
208
|
+
throw new UnsupportedMergeFormatError(format);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
|
|
211
|
+
throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
|
|
177
212
|
}
|
|
178
|
-
throw new UnsupportedMergeFormatError(format);
|
|
179
213
|
}
|
|
180
214
|
|
|
181
215
|
function closeSpeechResources(speechConfig: SpeechSDK.SpeechConfig, synthesizer: SpeechSDK.SpeechSynthesizer): void {
|
|
@@ -192,7 +226,7 @@ const ticksToMilliseconds = (ticks: number): number => Math.max(0, ticks) / 10_0
|
|
|
192
226
|
|
|
193
227
|
export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<SsmlSynthesisResult> {
|
|
194
228
|
if (config.signal?.aborted) {
|
|
195
|
-
throw
|
|
229
|
+
throw new SynthesisCancelledError();
|
|
196
230
|
}
|
|
197
231
|
|
|
198
232
|
const speechConfig = createSpeechConfig(config);
|
|
@@ -217,24 +251,118 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
217
251
|
settled = true;
|
|
218
252
|
cleanup();
|
|
219
253
|
closeResources();
|
|
220
|
-
reject(
|
|
254
|
+
reject(toSynthesisError(error));
|
|
221
255
|
};
|
|
222
256
|
|
|
223
257
|
const boundaries: SsmlSynthesisResult["boundaries"] = [];
|
|
224
258
|
const visemes: SsmlSynthesisResult["visemes"] = [];
|
|
225
259
|
const bookmarks: SsmlSynthesisResult["bookmarks"] = [];
|
|
260
|
+
let sourceEventCursor = 0;
|
|
261
|
+
let generatedSourceMap: ReturnType<typeof getSsmlSourceMap> | undefined;
|
|
262
|
+
if (!config.sourceTextSegments && !config.sourceMarkers) {
|
|
263
|
+
try {
|
|
264
|
+
generatedSourceMap = getSsmlSourceMap(ssml);
|
|
265
|
+
} catch {
|
|
266
|
+
generatedSourceMap = undefined;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const sourceBaseOffset = config.sourceTextRange?.start ?? 0;
|
|
270
|
+
const sourceSegments =
|
|
271
|
+
config.sourceTextSegments ??
|
|
272
|
+
generatedSourceMap?.segments.map((segment) => ({
|
|
273
|
+
...segment,
|
|
274
|
+
range: {
|
|
275
|
+
start: segment.range.start + sourceBaseOffset,
|
|
276
|
+
end: segment.range.end + sourceBaseOffset,
|
|
277
|
+
},
|
|
278
|
+
sourceNodePath: [...segment.sourceNodePath],
|
|
279
|
+
})) ??
|
|
280
|
+
[];
|
|
281
|
+
const sourceMarkers =
|
|
282
|
+
config.sourceMarkers ??
|
|
283
|
+
generatedSourceMap?.markers.map((marker) => ({
|
|
284
|
+
...marker,
|
|
285
|
+
originalTextRange: {
|
|
286
|
+
start: marker.originalTextRange.start + sourceBaseOffset,
|
|
287
|
+
end: marker.originalTextRange.end + sourceBaseOffset,
|
|
288
|
+
},
|
|
289
|
+
sourceNodePath: [...marker.sourceNodePath],
|
|
290
|
+
})) ??
|
|
291
|
+
[];
|
|
292
|
+
const sourceText = sourceSegments.map((segment) => segment.text).join("");
|
|
293
|
+
const mapSourceEvent = (
|
|
294
|
+
text: string | undefined,
|
|
295
|
+
offsetHint: number | undefined,
|
|
296
|
+
markerName?: string,
|
|
297
|
+
): {
|
|
298
|
+
textRange?: { start: number; end: number };
|
|
299
|
+
originalTextRange?: { start: number; end: number };
|
|
300
|
+
sourceNodePath?: string[];
|
|
301
|
+
} => {
|
|
302
|
+
const marker = markerName ? sourceMarkers.find((candidate) => candidate.name === markerName) : undefined;
|
|
303
|
+
if (marker) {
|
|
304
|
+
return {
|
|
305
|
+
originalTextRange: { ...marker.originalTextRange },
|
|
306
|
+
sourceNodePath: [...marker.sourceNodePath],
|
|
307
|
+
textRange: { ...marker.originalTextRange },
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) return {};
|
|
311
|
+
const value = text ?? "";
|
|
312
|
+
let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? (offsetHint as number) : -1;
|
|
313
|
+
if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value)
|
|
314
|
+
localStart = -1;
|
|
315
|
+
if (localStart < 0 || localStart > sourceText.length) {
|
|
316
|
+
localStart = value ? sourceText.indexOf(value, sourceEventCursor) : sourceEventCursor;
|
|
317
|
+
if (localStart < 0) localStart = value ? sourceText.indexOf(value) : sourceEventCursor;
|
|
318
|
+
}
|
|
319
|
+
localStart = Math.max(0, localStart);
|
|
320
|
+
const localEnd = Math.min(sourceText.length, localStart + value.length);
|
|
321
|
+
sourceEventCursor = Math.max(sourceEventCursor, localEnd);
|
|
322
|
+
const baseStart = config.sourceTextRange?.start ?? sourceSegments[0]?.range.start ?? 0;
|
|
323
|
+
const fallbackRange = { start: baseStart + localStart, end: baseStart + localEnd };
|
|
324
|
+
const segment =
|
|
325
|
+
sourceSegments.find(({ range }) => range.start <= fallbackRange.start && range.end > fallbackRange.start) ??
|
|
326
|
+
sourceSegments.find(({ range }) => range.end > fallbackRange.start) ??
|
|
327
|
+
(value.length === 0
|
|
328
|
+
? sourceSegments.find(({ range }) => range.start <= fallbackRange.start && range.end >= fallbackRange.start)
|
|
329
|
+
: undefined);
|
|
330
|
+
return {
|
|
331
|
+
originalTextRange: { ...fallbackRange },
|
|
332
|
+
textRange: { ...fallbackRange },
|
|
333
|
+
...(segment
|
|
334
|
+
? { sourceNodePath: [...segment.sourceNodePath] }
|
|
335
|
+
: config.sourceNodePath
|
|
336
|
+
? { sourceNodePath: [...config.sourceNodePath] }
|
|
337
|
+
: {}),
|
|
338
|
+
};
|
|
339
|
+
};
|
|
226
340
|
synthesizer.wordBoundary = (_sender, event) => {
|
|
227
341
|
boundaries.push({
|
|
228
342
|
text: event.text,
|
|
229
343
|
audioOffsetMs: ticksToMilliseconds(event.audioOffset),
|
|
230
344
|
durationMs: ticksToMilliseconds(event.duration),
|
|
345
|
+
...mapSourceEvent(
|
|
346
|
+
event.text,
|
|
347
|
+
(event as SpeechSDK.SpeechSynthesisWordBoundaryEventArgs & { textOffset?: number }).textOffset,
|
|
348
|
+
),
|
|
231
349
|
});
|
|
232
350
|
};
|
|
233
351
|
synthesizer.visemeReceived = (_sender, event) => {
|
|
234
|
-
|
|
352
|
+
const eventWithOffset = event as SpeechSDK.SpeechSynthesisVisemeEventArgs & { textOffset?: number };
|
|
353
|
+
visemes.push({
|
|
354
|
+
visemeId: event.visemeId,
|
|
355
|
+
audioOffsetMs: ticksToMilliseconds(event.audioOffset),
|
|
356
|
+
...mapSourceEvent(undefined, eventWithOffset.textOffset),
|
|
357
|
+
});
|
|
235
358
|
};
|
|
236
359
|
synthesizer.bookmarkReached = (_sender, event) => {
|
|
237
|
-
|
|
360
|
+
const eventWithOffset = event as SpeechSDK.SpeechSynthesisBookmarkEventArgs & { textOffset?: number };
|
|
361
|
+
bookmarks.push({
|
|
362
|
+
name: event.text,
|
|
363
|
+
audioOffsetMs: ticksToMilliseconds(event.audioOffset),
|
|
364
|
+
...mapSourceEvent(undefined, eventWithOffset.textOffset, event.text),
|
|
365
|
+
});
|
|
238
366
|
};
|
|
239
367
|
|
|
240
368
|
const cb = (result: SpeechSDK.SpeechSynthesisResult) => {
|
|
@@ -258,8 +386,10 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
258
386
|
const requestId = (result as SpeechSDK.SpeechSynthesisResult & { resultId?: string }).resultId;
|
|
259
387
|
const addSourceMetadata = <T extends { audioOffsetMs: number }>(event: T): T => ({
|
|
260
388
|
...event,
|
|
261
|
-
...(config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {}),
|
|
262
|
-
...(config.sourceTextRange
|
|
389
|
+
...(config.sourceTextRange && !("textRange" in event) ? { textRange: { ...config.sourceTextRange } } : {}),
|
|
390
|
+
...(config.sourceTextRange && !("originalTextRange" in event)
|
|
391
|
+
? { originalTextRange: { ...config.sourceTextRange } }
|
|
392
|
+
: {}),
|
|
263
393
|
...(config.chunkIndex !== undefined ? { chunkIndex: config.chunkIndex } : {}),
|
|
264
394
|
...(config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {}),
|
|
265
395
|
...(requestId ? { requestId } : {}),
|
|
@@ -282,12 +412,12 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
282
412
|
|
|
283
413
|
try {
|
|
284
414
|
if (config.signal) {
|
|
285
|
-
abortHandler = () => rejectWithError(
|
|
415
|
+
abortHandler = () => rejectWithError(new SynthesisCancelledError());
|
|
286
416
|
config.signal.addEventListener("abort", abortHandler, { once: true });
|
|
287
417
|
}
|
|
288
418
|
if (config.timeoutMs !== undefined && config.timeoutMs > 0) {
|
|
289
419
|
timeout = setTimeout(
|
|
290
|
-
() => rejectWithError(`Speech synthesis timed out after ${config.timeoutMs} ms.`),
|
|
420
|
+
() => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeoutMs} ms.`)),
|
|
291
421
|
config.timeoutMs,
|
|
292
422
|
);
|
|
293
423
|
}
|
|
@@ -334,7 +464,11 @@ export async function synthesizeSsmlChunks(
|
|
|
334
464
|
const result = await synthesizeSsml(input.ssml, {
|
|
335
465
|
...config,
|
|
336
466
|
...(input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {}),
|
|
337
|
-
...(input.sourceNodePath
|
|
467
|
+
...((input.sourceNodePath ?? config.sourceNodePath)
|
|
468
|
+
? { sourceNodePath: [...(input.sourceNodePath ?? config.sourceNodePath ?? [])] }
|
|
469
|
+
: {}),
|
|
470
|
+
...(input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {}),
|
|
471
|
+
...(input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {}),
|
|
338
472
|
chunkIndex: index,
|
|
339
473
|
onProgress: undefined,
|
|
340
474
|
});
|
|
@@ -362,32 +496,23 @@ export async function synthesizeSsmlChunks(
|
|
|
362
496
|
throw error;
|
|
363
497
|
}
|
|
364
498
|
}
|
|
365
|
-
return mergeSynthesisResults(results,
|
|
499
|
+
return mergeSynthesisResults(results, {
|
|
500
|
+
format: (config.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3") as AzureTtsOutputFormat,
|
|
501
|
+
});
|
|
366
502
|
}
|
|
367
503
|
|
|
368
504
|
/** Concatenates audio buffers and shifts all synchronization events by prior chunk durations. */
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
format,
|
|
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
|
-
}
|
|
505
|
+
function createMergedResult(
|
|
506
|
+
results: readonly SsmlSynthesisResult[],
|
|
507
|
+
audioData: ArrayBuffer,
|
|
508
|
+
format: string,
|
|
509
|
+
): MergedSynthesisResult {
|
|
385
510
|
const boundaries: NonNullable<SsmlSynthesisResult["boundaries"]> = [];
|
|
386
511
|
const visemes: NonNullable<SsmlSynthesisResult["visemes"]> = [];
|
|
387
512
|
const bookmarks: NonNullable<SsmlSynthesisResult["bookmarks"]> = [];
|
|
388
513
|
let durationOffset = 0;
|
|
389
514
|
|
|
390
|
-
for (const result of results) {
|
|
515
|
+
for (const [resultIndex, result] of results.entries()) {
|
|
391
516
|
const chunkBoundaries =
|
|
392
517
|
result.boundaries && result.boundaries.length > 0
|
|
393
518
|
? result.boundaries
|
|
@@ -400,7 +525,7 @@ export function mergeSynthesisResults(results: readonly SsmlSynthesisResult[], f
|
|
|
400
525
|
...boundary,
|
|
401
526
|
audioOffsetMs: boundary.audioOffsetMs + durationOffset,
|
|
402
527
|
chunkAudioOffsetMs: boundary.chunkAudioOffsetMs ?? boundary.audioOffsetMs,
|
|
403
|
-
...(boundary.chunkIndex === undefined ? { chunkIndex:
|
|
528
|
+
...(boundary.chunkIndex === undefined ? { chunkIndex: resultIndex } : {}),
|
|
404
529
|
...(boundary.sourceNodePath ? { sourceNodePath: [...boundary.sourceNodePath] } : {}),
|
|
405
530
|
...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
|
|
406
531
|
...(textRange ? { textRange: { ...textRange } } : {}),
|
|
@@ -415,7 +540,7 @@ export function mergeSynthesisResults(results: readonly SsmlSynthesisResult[], f
|
|
|
415
540
|
...viseme,
|
|
416
541
|
audioOffsetMs: viseme.audioOffsetMs + durationOffset,
|
|
417
542
|
chunkAudioOffsetMs: viseme.chunkAudioOffsetMs ?? viseme.audioOffsetMs,
|
|
418
|
-
...(viseme.chunkIndex === undefined ? { chunkIndex:
|
|
543
|
+
...(viseme.chunkIndex === undefined ? { chunkIndex: resultIndex } : {}),
|
|
419
544
|
...(viseme.sourceNodePath ? { sourceNodePath: [...viseme.sourceNodePath] } : {}),
|
|
420
545
|
...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
|
|
421
546
|
...(textRange ? { textRange: { ...textRange } } : {}),
|
|
@@ -430,7 +555,7 @@ export function mergeSynthesisResults(results: readonly SsmlSynthesisResult[], f
|
|
|
430
555
|
...bookmark,
|
|
431
556
|
audioOffsetMs: bookmark.audioOffsetMs + durationOffset,
|
|
432
557
|
chunkAudioOffsetMs: bookmark.chunkAudioOffsetMs ?? bookmark.audioOffsetMs,
|
|
433
|
-
...(bookmark.chunkIndex === undefined ? { chunkIndex:
|
|
558
|
+
...(bookmark.chunkIndex === undefined ? { chunkIndex: resultIndex } : {}),
|
|
434
559
|
...(bookmark.sourceNodePath ? { sourceNodePath: [...bookmark.sourceNodePath] } : {}),
|
|
435
560
|
...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
|
|
436
561
|
...(textRange ? { textRange: { ...textRange } } : {}),
|
|
@@ -441,8 +566,9 @@ export function mergeSynthesisResults(results: readonly SsmlSynthesisResult[], f
|
|
|
441
566
|
}
|
|
442
567
|
|
|
443
568
|
return {
|
|
444
|
-
audioData
|
|
569
|
+
audioData,
|
|
445
570
|
durationMs: durationOffset,
|
|
571
|
+
mimeType: resolveMimeType(format),
|
|
446
572
|
...(boundaries.length > 0 ? { boundaries, wordBoundary: boundaries, wordBoundaries: boundaries } : {}),
|
|
447
573
|
...(visemes.length > 0 ? { visemes } : {}),
|
|
448
574
|
...(bookmarks.length > 0 ? { bookmarks } : {}),
|
|
@@ -451,6 +577,43 @@ export function mergeSynthesisResults(results: readonly SsmlSynthesisResult[], f
|
|
|
451
577
|
};
|
|
452
578
|
}
|
|
453
579
|
|
|
580
|
+
export function mergeSynthesisResults(
|
|
581
|
+
results: readonly SsmlSynthesisResult[],
|
|
582
|
+
options: AsyncMergeSynthesisOptions,
|
|
583
|
+
): Promise<MergedSynthesisResult>;
|
|
584
|
+
export function mergeSynthesisResults(
|
|
585
|
+
results: readonly SsmlSynthesisResult[],
|
|
586
|
+
options: MergeAudioOptions,
|
|
587
|
+
): MergedSynthesisResult;
|
|
588
|
+
export function mergeSynthesisResults(
|
|
589
|
+
results: readonly SsmlSynthesisResult[],
|
|
590
|
+
options: MergeSynthesisOptions | string,
|
|
591
|
+
): SsmlSynthesisResult | Promise<SsmlSynthesisResult> {
|
|
592
|
+
const resolvedOptions: MergeSynthesisOptions =
|
|
593
|
+
typeof options === "string" ? { format: options as AzureTtsOutputFormat } : options;
|
|
594
|
+
const format = resolvedOptions?.format;
|
|
595
|
+
if (!format) throw new UnsupportedMergeFormatError("");
|
|
596
|
+
const buffers = results.map((result) => result.audioData);
|
|
597
|
+
if (resolvedOptions.customMerger) {
|
|
598
|
+
return Promise.resolve()
|
|
599
|
+
.then(() => resolvedOptions.customMerger?.(buffers, format))
|
|
600
|
+
.then((merged) => {
|
|
601
|
+
if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
|
|
602
|
+
return createMergedResult(results, merged, format);
|
|
603
|
+
})
|
|
604
|
+
.catch((error: unknown) => {
|
|
605
|
+
if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
|
|
606
|
+
throw new MergeError(`Custom audio merger failed for format "${format}".`, error);
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
try {
|
|
610
|
+
return createMergedResult(results, mergeAudioBuffers(buffers, { format }), format);
|
|
611
|
+
} catch (error) {
|
|
612
|
+
if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
|
|
613
|
+
throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
454
617
|
/** Backward-compatible audio-only synthesis helper. */
|
|
455
618
|
export async function synthesizeSpeech(ssml: string, config: TtsConfig): Promise<ArrayBuffer> {
|
|
456
619
|
return (await synthesizeSsml(ssml, config)).audioData;
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { SsmlTextRange } from "@ssml-builder-js/ssml-core";
|
|
1
|
+
import type { SsmlSourceMarker, SsmlSourceTextSegment, SsmlTextRange } from "@ssml-builder-js/ssml-core";
|
|
2
|
+
import type { AzureTtsOutputFormat } from "./outputFormats.ts";
|
|
2
3
|
|
|
3
4
|
export interface TtsConfig {
|
|
4
5
|
signal?: AbortSignal;
|
|
@@ -14,6 +15,9 @@ export interface TtsConfig {
|
|
|
14
15
|
/** Metadata used to map synchronization events back to the source document. */
|
|
15
16
|
chunkIndex?: number;
|
|
16
17
|
sourceNodePath?: string[];
|
|
18
|
+
/** Exact source text segments used to map individual Azure events. */
|
|
19
|
+
sourceTextSegments?: SsmlSourceTextSegment[];
|
|
20
|
+
sourceMarkers?: SsmlSourceMarker[];
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
export type SynthesisChunkStatus = "pending" | "synthesizing" | "success" | "failed";
|
|
@@ -71,16 +75,28 @@ export interface SsmlSynthesisResult {
|
|
|
71
75
|
requestId?: string;
|
|
72
76
|
/** Original plain-text range represented by the result. */
|
|
73
77
|
textRange?: { start: number; end: number };
|
|
78
|
+
/** MIME type of a result produced by an explicit merge operation. */
|
|
79
|
+
mimeType?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface MergedSynthesisResult extends SsmlSynthesisResult {
|
|
83
|
+
mimeType: string;
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
export interface SsmlSynthesisChunk {
|
|
77
87
|
ssml: string;
|
|
78
88
|
originalTextRange?: { start: number; end: number };
|
|
79
89
|
sourceNodePath?: string[];
|
|
90
|
+
sourceTextSegments?: SsmlSourceTextSegment[];
|
|
91
|
+
sourceMarkers?: SsmlSourceMarker[];
|
|
80
92
|
}
|
|
81
93
|
|
|
82
94
|
export interface SynthesizeChunksOptions {
|
|
83
95
|
onProgress?: (event: SynthesisProgressEvent) => void;
|
|
96
|
+
outputFormat?: AzureTtsOutputFormat | string;
|
|
97
|
+
signal?: AbortSignal;
|
|
98
|
+
timeoutMs?: number;
|
|
99
|
+
sourceNodePath?: string[];
|
|
84
100
|
}
|
|
85
101
|
|
|
86
102
|
export interface SynthesisProgressEvent {
|
package/test/synthesis.test.ts
CHANGED
|
@@ -90,6 +90,70 @@ test("synthesizeSsml returns word boundaries, visemes, bookmarks, and duration",
|
|
|
90
90
|
assert.equal(result.durationMs, 400);
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
+
test("synthesizeSsml maps each synchronization event to its source range and node", async (t) => {
|
|
94
|
+
const originalFromEndpoint = SpeechSDK.SpeechConfig.fromEndpoint;
|
|
95
|
+
t.mock.method(SpeechSDK.SpeechConfig, "fromEndpoint", (speechEndpoint, key) =>
|
|
96
|
+
originalFromEndpoint(speechEndpoint, String(key)),
|
|
97
|
+
);
|
|
98
|
+
t.mock.method(
|
|
99
|
+
SpeechSDK.SpeechSynthesizer.prototype,
|
|
100
|
+
"speakSsmlAsync",
|
|
101
|
+
function (this: SpeechSDK.SpeechSynthesizer, _ssml, callback) {
|
|
102
|
+
this.wordBoundary?.(this, {
|
|
103
|
+
text: "Hello",
|
|
104
|
+
textOffset: 7,
|
|
105
|
+
audioOffset: 0,
|
|
106
|
+
duration: 100_000,
|
|
107
|
+
} as SpeechSDK.SpeechSynthesisWordBoundaryEventArgs);
|
|
108
|
+
this.visemeReceived?.(this, {
|
|
109
|
+
visemeId: 3,
|
|
110
|
+
textOffset: 7,
|
|
111
|
+
audioOffset: 100_000,
|
|
112
|
+
} as SpeechSDK.SpeechSynthesisVisemeEventArgs);
|
|
113
|
+
this.bookmarkReached?.(this, {
|
|
114
|
+
text: "chapter",
|
|
115
|
+
audioOffset: 200_000,
|
|
116
|
+
} as SpeechSDK.SpeechSynthesisBookmarkEventArgs);
|
|
117
|
+
callback?.({
|
|
118
|
+
audioData: new ArrayBuffer(1),
|
|
119
|
+
audioDuration: 300_000,
|
|
120
|
+
errorDetails: "",
|
|
121
|
+
reason: SpeechSDK.ResultReason.SynthesizingAudioCompleted,
|
|
122
|
+
} as SpeechSDK.SpeechSynthesisResult);
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
t.mock.method(SpeechSDK.SpeechSynthesizer.prototype, "close", () => undefined);
|
|
126
|
+
|
|
127
|
+
const result = await synthesizeSsml(
|
|
128
|
+
'<speak version="1.0" xml:lang="en-US"><voice name="en-US-JennyNeural">prefix <prosody rate="slow">Hello</prosody><bookmark mark="chapter"/></voice></speak>',
|
|
129
|
+
{
|
|
130
|
+
endpoint,
|
|
131
|
+
subscriptionKey,
|
|
132
|
+
region,
|
|
133
|
+
sourceTextRange: { start: 100, end: 112 },
|
|
134
|
+
sourceTextSegments: [
|
|
135
|
+
{ text: "prefix ", range: { start: 100, end: 107 }, sourceNodePath: ["speak", "voice[0]"] },
|
|
136
|
+
{ text: "Hello", range: { start: 107, end: 112 }, sourceNodePath: ["speak", "voice[0]", "prosody[1]"] },
|
|
137
|
+
],
|
|
138
|
+
sourceMarkers: [
|
|
139
|
+
{
|
|
140
|
+
kind: "bookmark",
|
|
141
|
+
name: "chapter",
|
|
142
|
+
originalTextRange: { start: 112, end: 112 },
|
|
143
|
+
sourceNodePath: ["speak", "voice[0]", "bookmark[2]"],
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
assert.deepEqual(result.boundaries?.[0]?.originalTextRange, { start: 107, end: 112 });
|
|
150
|
+
assert.deepEqual(result.boundaries?.[0]?.sourceNodePath, ["speak", "voice[0]", "prosody[1]"]);
|
|
151
|
+
assert.deepEqual(result.visemes?.[0]?.originalTextRange, { start: 107, end: 107 });
|
|
152
|
+
assert.deepEqual(result.visemes?.[0]?.sourceNodePath, ["speak", "voice[0]", "prosody[1]"]);
|
|
153
|
+
assert.deepEqual(result.bookmarks?.[0]?.originalTextRange, { start: 112, end: 112 });
|
|
154
|
+
assert.deepEqual(result.bookmarks?.[0]?.sourceNodePath, ["speak", "voice[0]", "bookmark[2]"]);
|
|
155
|
+
});
|
|
156
|
+
|
|
93
157
|
test("synthesizeSpeech aborts the SDK request on timeout and settles its promise", async (t) => {
|
|
94
158
|
let closeCount = 0;
|
|
95
159
|
let resultCallback: ((result: SpeechSDK.SpeechSynthesisResult) => void) | undefined;
|
|
@@ -5,22 +5,29 @@ import { mergeSynthesisResults, synthesizeSsmlSafe } from "../src/index.ts";
|
|
|
5
5
|
const audio = (values: number[]): ArrayBuffer => Uint8Array.from(values).buffer;
|
|
6
6
|
|
|
7
7
|
test("mergeSynthesisResults concatenates audio and offsets synchronization events", () => {
|
|
8
|
-
const result = mergeSynthesisResults(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
8
|
+
const result = mergeSynthesisResults(
|
|
9
|
+
[
|
|
10
|
+
{
|
|
11
|
+
audioData: audio([1, 2]),
|
|
12
|
+
durationMs: 100,
|
|
13
|
+
boundaries: [
|
|
14
|
+
{ text: "one", audioOffsetMs: 20, durationMs: 30, textRange: { start: 0, end: 3 }, requestId: "a" },
|
|
15
|
+
],
|
|
16
|
+
visemes: [{ visemeId: 1, audioOffsetMs: 40 }],
|
|
17
|
+
bookmarks: [{ name: "first", audioOffsetMs: 50 }],
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
audioData: audio([3, 4, 5]),
|
|
21
|
+
durationMs: 250,
|
|
22
|
+
boundaries: [
|
|
23
|
+
{ text: "two", audioOffsetMs: 10, durationMs: 20, textRange: { start: 3, end: 6 }, requestId: "b" },
|
|
24
|
+
],
|
|
25
|
+
visemes: [{ visemeId: 2, audioOffsetMs: 15 }],
|
|
26
|
+
bookmarks: [{ name: "second", audioOffsetMs: 25 }],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
{ format: "audio-16khz-128kbitrate-mono-mp3" },
|
|
30
|
+
);
|
|
24
31
|
|
|
25
32
|
assert.deepEqual([...new Uint8Array(result.audioData)], [1, 2, 3, 4, 5]);
|
|
26
33
|
assert.equal(result.durationMs, 350);
|
|
@@ -31,7 +31,7 @@ const validSsml = (text: string) =>
|
|
|
31
31
|
`<speak version="1.0" xml:lang="en-US"><voice name="en-US-JennyNeural">${text}</voice></speak>`;
|
|
32
32
|
|
|
33
33
|
test("mergeAudioBuffers rebuilds one valid WAV header", () => {
|
|
34
|
-
const merged = mergeAudioBuffers([wav([1, 2]), wav([3, 4, 5])], "riff-16khz-16bit-mono-pcm");
|
|
34
|
+
const merged = mergeAudioBuffers([wav([1, 2]), wav([3, 4, 5])], { format: "riff-16khz-16bit-mono-pcm" });
|
|
35
35
|
const bytes = new Uint8Array(merged);
|
|
36
36
|
const view = new DataView(merged);
|
|
37
37
|
assert.equal(new TextDecoder().decode(bytes.slice(0, 4)), "RIFF");
|
|
@@ -51,14 +51,18 @@ test("mergeAudioBuffers removes per-buffer ID3 tags from MP3 streams", () => {
|
|
|
51
51
|
second.set(tag);
|
|
52
52
|
second[tag.length] = 3;
|
|
53
53
|
assert.deepEqual(
|
|
54
|
-
[
|
|
54
|
+
[
|
|
55
|
+
...new Uint8Array(
|
|
56
|
+
mergeAudioBuffers([first.buffer, second.buffer], { format: "audio-16khz-128kbitrate-mono-mp3" }),
|
|
57
|
+
),
|
|
58
|
+
],
|
|
55
59
|
[1, 2, 3],
|
|
56
60
|
);
|
|
57
61
|
});
|
|
58
62
|
|
|
59
63
|
test("mergeAudioBuffers rejects container formats that require remultiplexing", () => {
|
|
60
64
|
assert.throws(
|
|
61
|
-
() => mergeAudioBuffers([new ArrayBuffer(1)], "webm-24khz-16bit-mono-opus"),
|
|
65
|
+
() => mergeAudioBuffers([new ArrayBuffer(1)], { format: "webm-24khz-16bit-mono-opus" }),
|
|
62
66
|
(error: unknown) => error instanceof UnsupportedMergeFormatError,
|
|
63
67
|
);
|
|
64
68
|
});
|