@ssml-builder-js/azure-tts-client 2.13.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 +22 -0
- package/dist/index.d.mts +169 -17
- package/dist/index.d.ts +169 -17
- package/dist/index.js +619 -47
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +606 -45
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +39 -7
- package/src/errors.ts +74 -0
- package/src/index.ts +25 -3
- package/src/outputFormats.ts +14 -3
- package/src/safe.ts +273 -23
- package/src/synthesis.ts +435 -26
- package/src/types.ts +48 -2
- package/src/voiceCatalog.ts +15 -0
- package/test/synthesis.test.ts +64 -0
- package/test/v213-pipeline.test.ts +27 -16
- package/test/v214-pipeline.test.ts +114 -0
- package/test/v215-pipeline.test.ts +104 -0
package/src/synthesis.ts
CHANGED
|
@@ -1,7 +1,216 @@
|
|
|
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";
|
|
19
|
+
|
|
20
|
+
export type MergeAudioFormat = "wav" | "mp3" | "raw";
|
|
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
|
+
|
|
34
|
+
function ascii(bytes: Uint8Array, offset: number, value: string): boolean {
|
|
35
|
+
return [...value].every((character, index) => bytes[offset + index] === character.charCodeAt(0));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readUint32(bytes: Uint8Array, offset: number): number {
|
|
39
|
+
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(offset, true);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface RiffChunk {
|
|
43
|
+
id: string;
|
|
44
|
+
data: Uint8Array;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface ParsedWav {
|
|
48
|
+
chunks: RiffChunk[];
|
|
49
|
+
data: Uint8Array;
|
|
50
|
+
format: Uint8Array;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseWav(buffer: ArrayBuffer): ParsedWav {
|
|
54
|
+
const bytes = new Uint8Array(buffer);
|
|
55
|
+
if (bytes.byteLength < 12 || !ascii(bytes, 0, "RIFF") || !ascii(bytes, 8, "WAVE")) {
|
|
56
|
+
throw new Error("Invalid WAV/RIFF audio buffer.");
|
|
57
|
+
}
|
|
58
|
+
const chunks: RiffChunk[] = [];
|
|
59
|
+
const dataParts: Uint8Array[] = [];
|
|
60
|
+
let format: Uint8Array | undefined;
|
|
61
|
+
let offset = 12;
|
|
62
|
+
while (offset < bytes.byteLength) {
|
|
63
|
+
if (offset + 8 > bytes.byteLength) throw new Error("Invalid WAV chunk header.");
|
|
64
|
+
const id = String.fromCharCode(...bytes.slice(offset, offset + 4));
|
|
65
|
+
const size = readUint32(bytes, offset + 4);
|
|
66
|
+
const dataStart = offset + 8;
|
|
67
|
+
const dataEnd = dataStart + size;
|
|
68
|
+
if (dataEnd > bytes.byteLength) throw new Error(`WAV chunk "${id}" exceeds the audio buffer.`);
|
|
69
|
+
const data = bytes.slice(dataStart, dataEnd);
|
|
70
|
+
chunks.push({ id, data });
|
|
71
|
+
if (id === "fmt ") format ??= data;
|
|
72
|
+
if (id === "data") dataParts.push(data);
|
|
73
|
+
offset = dataEnd + (size & 1);
|
|
74
|
+
if (offset > bytes.byteLength) throw new Error("Invalid WAV chunk padding.");
|
|
75
|
+
}
|
|
76
|
+
if (!format || dataParts.length === 0) throw new Error("WAV audio must contain fmt and data chunks.");
|
|
77
|
+
const dataLength = dataParts.reduce((total, part) => total + part.byteLength, 0);
|
|
78
|
+
const data = new Uint8Array(dataLength);
|
|
79
|
+
let dataOffset = 0;
|
|
80
|
+
for (const part of dataParts) {
|
|
81
|
+
data.set(part, dataOffset);
|
|
82
|
+
dataOffset += part.byteLength;
|
|
83
|
+
}
|
|
84
|
+
return { chunks, data, format };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function writeUint32(target: Uint8Array, offset: number, value: number): void {
|
|
88
|
+
new DataView(target.buffer).setUint32(offset, value, true);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function writeChunk(target: Uint8Array, offset: number, id: string, data: Uint8Array): number {
|
|
92
|
+
for (let index = 0; index < 4; index += 1) target[offset + index] = id.charCodeAt(index) ?? 0;
|
|
93
|
+
writeUint32(target, offset + 4, data.byteLength);
|
|
94
|
+
target.set(data, offset + 8);
|
|
95
|
+
const end = offset + 8 + data.byteLength;
|
|
96
|
+
if (data.byteLength & 1) target[end] = 0;
|
|
97
|
+
return end + (data.byteLength & 1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function mergeWavBuffers(buffers: readonly ArrayBuffer[]): ArrayBuffer {
|
|
101
|
+
if (buffers.length === 0) return new ArrayBuffer(0);
|
|
102
|
+
const parsed = buffers.map(parseWav);
|
|
103
|
+
const first = parsed[0];
|
|
104
|
+
if (!first) throw new Error("At least one WAV buffer is required.");
|
|
105
|
+
if (
|
|
106
|
+
parsed.some(
|
|
107
|
+
(item) => item.format.length !== first.format.length || item.format.some((value, i) => value !== first.format[i]),
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
throw new Error("WAV buffers have incompatible fmt chunks.");
|
|
111
|
+
const dataLength = parsed.reduce((total, item) => total + item.data.byteLength, 0);
|
|
112
|
+
const nonDataLength = first.chunks.reduce(
|
|
113
|
+
(total, chunk) => (chunk.id === "data" ? total : total + 8 + chunk.data.byteLength + (chunk.data.byteLength & 1)),
|
|
114
|
+
0,
|
|
115
|
+
);
|
|
116
|
+
const outputLength = 12 + nonDataLength + 8 + dataLength + (dataLength & 1);
|
|
117
|
+
if (outputLength - 8 > 0xffffffff) throw new RangeError("Merged WAV exceeds the RIFF format size limit.");
|
|
118
|
+
const output = new Uint8Array(outputLength);
|
|
119
|
+
output.set(Uint8Array.from([0x52, 0x49, 0x46, 0x46]), 0);
|
|
120
|
+
writeUint32(output, 4, outputLength - 8);
|
|
121
|
+
output.set(Uint8Array.from([0x57, 0x41, 0x56, 0x45]), 8);
|
|
122
|
+
let outputOffset = 12;
|
|
123
|
+
let dataWritten = false;
|
|
124
|
+
for (const chunk of first.chunks) {
|
|
125
|
+
if (chunk.id === "data") {
|
|
126
|
+
if (dataWritten) continue;
|
|
127
|
+
const data = new Uint8Array(dataLength);
|
|
128
|
+
let dataOffset = 0;
|
|
129
|
+
for (const item of parsed) {
|
|
130
|
+
data.set(item.data, dataOffset);
|
|
131
|
+
dataOffset += item.data.byteLength;
|
|
132
|
+
}
|
|
133
|
+
outputOffset = writeChunk(output, outputOffset, "data", data);
|
|
134
|
+
dataWritten = true;
|
|
135
|
+
} else {
|
|
136
|
+
outputOffset = writeChunk(output, outputOffset, chunk.id, chunk.data);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!dataWritten) throw new Error("WAV audio must contain a data chunk.");
|
|
140
|
+
return output.buffer;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function skipId3v2(bytes: Uint8Array): number {
|
|
144
|
+
if (!ascii(bytes, 0, "ID3") || bytes.byteLength < 10) return 0;
|
|
145
|
+
const size = [bytes[6], bytes[7], bytes[8], bytes[9]].reduce((total, value) => (total << 7) | (value & 0x7f), 0);
|
|
146
|
+
const hasFooter = (bytes[5] & 0x10) !== 0;
|
|
147
|
+
return Math.min(bytes.byteLength, 10 + size + (hasFooter ? 10 : 0));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function stripMp3Tags(buffer: ArrayBuffer): Uint8Array {
|
|
151
|
+
const bytes = new Uint8Array(buffer);
|
|
152
|
+
const start = skipId3v2(bytes);
|
|
153
|
+
const end =
|
|
154
|
+
bytes.byteLength >= 128 && ascii(bytes, bytes.byteLength - 128, "TAG") ? bytes.byteLength - 128 : bytes.byteLength;
|
|
155
|
+
return bytes.slice(Math.min(start, end), end);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isMp3Format(format: string): boolean {
|
|
159
|
+
return /(?:mp3|mpeg)/i.test(format);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isWavFormat(format: string): boolean {
|
|
163
|
+
return /(?:wav|wave|riff)/i.test(format);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isRawFormat(format: string): boolean {
|
|
167
|
+
return /^raw(?:-|$)/i.test(format);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Returns whether the named output format can be safely concatenated without re-multiplexing. */
|
|
171
|
+
export function resolveMergeAudioFormat(format: string): MergeAudioFormat | undefined {
|
|
172
|
+
if (isWavFormat(format)) return "wav";
|
|
173
|
+
if (isMp3Format(format)) return "mp3";
|
|
174
|
+
if (isRawFormat(format)) return "raw";
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function canMergeAudioFormat(format: string): boolean {
|
|
179
|
+
return resolveMergeAudioFormat(format) !== undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Merges audio buffers while preserving the invariants of supported containers. */
|
|
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;
|
|
198
|
+
}
|
|
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;
|
|
207
|
+
}
|
|
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);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
5
214
|
|
|
6
215
|
function closeSpeechResources(speechConfig: SpeechSDK.SpeechConfig, synthesizer: SpeechSDK.SpeechSynthesizer): void {
|
|
7
216
|
try {
|
|
@@ -17,7 +226,7 @@ const ticksToMilliseconds = (ticks: number): number => Math.max(0, ticks) / 10_0
|
|
|
17
226
|
|
|
18
227
|
export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<SsmlSynthesisResult> {
|
|
19
228
|
if (config.signal?.aborted) {
|
|
20
|
-
throw
|
|
229
|
+
throw new SynthesisCancelledError();
|
|
21
230
|
}
|
|
22
231
|
|
|
23
232
|
const speechConfig = createSpeechConfig(config);
|
|
@@ -42,24 +251,118 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
42
251
|
settled = true;
|
|
43
252
|
cleanup();
|
|
44
253
|
closeResources();
|
|
45
|
-
reject(
|
|
254
|
+
reject(toSynthesisError(error));
|
|
46
255
|
};
|
|
47
256
|
|
|
48
257
|
const boundaries: SsmlSynthesisResult["boundaries"] = [];
|
|
49
258
|
const visemes: SsmlSynthesisResult["visemes"] = [];
|
|
50
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
|
+
};
|
|
51
340
|
synthesizer.wordBoundary = (_sender, event) => {
|
|
52
341
|
boundaries.push({
|
|
53
342
|
text: event.text,
|
|
54
343
|
audioOffsetMs: ticksToMilliseconds(event.audioOffset),
|
|
55
344
|
durationMs: ticksToMilliseconds(event.duration),
|
|
345
|
+
...mapSourceEvent(
|
|
346
|
+
event.text,
|
|
347
|
+
(event as SpeechSDK.SpeechSynthesisWordBoundaryEventArgs & { textOffset?: number }).textOffset,
|
|
348
|
+
),
|
|
56
349
|
});
|
|
57
350
|
};
|
|
58
351
|
synthesizer.visemeReceived = (_sender, event) => {
|
|
59
|
-
|
|
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
|
+
});
|
|
60
358
|
};
|
|
61
359
|
synthesizer.bookmarkReached = (_sender, event) => {
|
|
62
|
-
|
|
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
|
+
});
|
|
63
366
|
};
|
|
64
367
|
|
|
65
368
|
const cb = (result: SpeechSDK.SpeechSynthesisResult) => {
|
|
@@ -83,7 +386,12 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
83
386
|
const requestId = (result as SpeechSDK.SpeechSynthesisResult & { resultId?: string }).resultId;
|
|
84
387
|
const addSourceMetadata = <T extends { audioOffsetMs: number }>(event: T): T => ({
|
|
85
388
|
...event,
|
|
86
|
-
...(config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {}),
|
|
389
|
+
...(config.sourceTextRange && !("textRange" in event) ? { textRange: { ...config.sourceTextRange } } : {}),
|
|
390
|
+
...(config.sourceTextRange && !("originalTextRange" in event)
|
|
391
|
+
? { originalTextRange: { ...config.sourceTextRange } }
|
|
392
|
+
: {}),
|
|
393
|
+
...(config.chunkIndex !== undefined ? { chunkIndex: config.chunkIndex } : {}),
|
|
394
|
+
...(config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {}),
|
|
87
395
|
...(requestId ? { requestId } : {}),
|
|
88
396
|
});
|
|
89
397
|
const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
|
|
@@ -104,12 +412,12 @@ export async function synthesizeSsml(ssml: string, config: TtsConfig): Promise<S
|
|
|
104
412
|
|
|
105
413
|
try {
|
|
106
414
|
if (config.signal) {
|
|
107
|
-
abortHandler = () => rejectWithError(
|
|
415
|
+
abortHandler = () => rejectWithError(new SynthesisCancelledError());
|
|
108
416
|
config.signal.addEventListener("abort", abortHandler, { once: true });
|
|
109
417
|
}
|
|
110
418
|
if (config.timeoutMs !== undefined && config.timeoutMs > 0) {
|
|
111
419
|
timeout = setTimeout(
|
|
112
|
-
() => rejectWithError(`Speech synthesis timed out after ${config.timeoutMs} ms.`),
|
|
420
|
+
() => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeoutMs} ms.`)),
|
|
113
421
|
config.timeoutMs,
|
|
114
422
|
);
|
|
115
423
|
}
|
|
@@ -127,66 +435,129 @@ export async function synthesizeSsmlChunks(
|
|
|
127
435
|
): Promise<SsmlSynthesisResult> {
|
|
128
436
|
const results: SsmlSynthesisResult[] = [];
|
|
129
437
|
const totalChunks = chunks.length;
|
|
438
|
+
const report = (event: SynthesisProgressEvent): void => config.onProgress?.(event);
|
|
130
439
|
for (const [index, chunk] of chunks.entries()) {
|
|
131
440
|
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
441
|
+
report({
|
|
442
|
+
currentChunk: index,
|
|
443
|
+
totalChunks,
|
|
444
|
+
percent: totalChunks === 0 ? 100 : Math.round((index / totalChunks) * 100),
|
|
445
|
+
chunkIndex: index,
|
|
446
|
+
originalTextRange: input.originalTextRange,
|
|
447
|
+
status: "pending",
|
|
448
|
+
durationMs: 0,
|
|
136
449
|
});
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
450
|
+
}
|
|
451
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
452
|
+
const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
|
|
453
|
+
report({
|
|
454
|
+
currentChunk: index,
|
|
140
455
|
totalChunks,
|
|
141
|
-
percent: totalChunks === 0 ? 100 : Math.round((
|
|
456
|
+
percent: totalChunks === 0 ? 100 : Math.round((index / totalChunks) * 100),
|
|
457
|
+
chunkIndex: index,
|
|
458
|
+
originalTextRange: input.originalTextRange,
|
|
459
|
+
status: "synthesizing",
|
|
460
|
+
durationMs: 0,
|
|
142
461
|
});
|
|
462
|
+
const startedAt = Date.now();
|
|
463
|
+
try {
|
|
464
|
+
const result = await synthesizeSsml(input.ssml, {
|
|
465
|
+
...config,
|
|
466
|
+
...(input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {}),
|
|
467
|
+
...((input.sourceNodePath ?? config.sourceNodePath)
|
|
468
|
+
? { sourceNodePath: [...(input.sourceNodePath ?? config.sourceNodePath ?? [])] }
|
|
469
|
+
: {}),
|
|
470
|
+
...(input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {}),
|
|
471
|
+
...(input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {}),
|
|
472
|
+
chunkIndex: index,
|
|
473
|
+
onProgress: undefined,
|
|
474
|
+
});
|
|
475
|
+
results.push(result);
|
|
476
|
+
report({
|
|
477
|
+
currentChunk: index + 1,
|
|
478
|
+
totalChunks,
|
|
479
|
+
percent: totalChunks === 0 ? 100 : Math.round(((index + 1) / totalChunks) * 100),
|
|
480
|
+
chunkIndex: index,
|
|
481
|
+
originalTextRange: input.originalTextRange,
|
|
482
|
+
status: "success",
|
|
483
|
+
durationMs: Date.now() - startedAt,
|
|
484
|
+
});
|
|
485
|
+
} catch (error) {
|
|
486
|
+
report({
|
|
487
|
+
currentChunk: index,
|
|
488
|
+
totalChunks,
|
|
489
|
+
percent: totalChunks === 0 ? 100 : Math.round((index / totalChunks) * 100),
|
|
490
|
+
chunkIndex: index,
|
|
491
|
+
originalTextRange: input.originalTextRange,
|
|
492
|
+
status: "failed",
|
|
493
|
+
durationMs: Date.now() - startedAt,
|
|
494
|
+
error,
|
|
495
|
+
});
|
|
496
|
+
throw error;
|
|
497
|
+
}
|
|
143
498
|
}
|
|
144
|
-
return mergeSynthesisResults(results
|
|
499
|
+
return mergeSynthesisResults(results, {
|
|
500
|
+
format: (config.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3") as AzureTtsOutputFormat,
|
|
501
|
+
});
|
|
145
502
|
}
|
|
146
503
|
|
|
147
504
|
/** Concatenates audio buffers and shifts all synchronization events by prior chunk durations. */
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
505
|
+
function createMergedResult(
|
|
506
|
+
results: readonly SsmlSynthesisResult[],
|
|
507
|
+
audioData: ArrayBuffer,
|
|
508
|
+
format: string,
|
|
509
|
+
): MergedSynthesisResult {
|
|
151
510
|
const boundaries: NonNullable<SsmlSynthesisResult["boundaries"]> = [];
|
|
152
511
|
const visemes: NonNullable<SsmlSynthesisResult["visemes"]> = [];
|
|
153
512
|
const bookmarks: NonNullable<SsmlSynthesisResult["bookmarks"]> = [];
|
|
154
|
-
let byteOffset = 0;
|
|
155
513
|
let durationOffset = 0;
|
|
156
514
|
|
|
157
|
-
for (const result of results) {
|
|
158
|
-
audioData.set(new Uint8Array(result.audioData), byteOffset);
|
|
159
|
-
byteOffset += result.audioData.byteLength;
|
|
515
|
+
for (const [resultIndex, result] of results.entries()) {
|
|
160
516
|
const chunkBoundaries =
|
|
161
517
|
result.boundaries && result.boundaries.length > 0
|
|
162
518
|
? result.boundaries
|
|
163
519
|
: (result.wordBoundary ?? result.wordBoundaries ?? []);
|
|
164
520
|
for (const boundary of chunkBoundaries) {
|
|
165
521
|
const textRange = boundary.textRange ?? result.textRange;
|
|
522
|
+
const originalTextRange = boundary.originalTextRange ?? textRange;
|
|
166
523
|
const requestId = boundary.requestId ?? result.requestId;
|
|
167
524
|
boundaries.push({
|
|
168
525
|
...boundary,
|
|
169
526
|
audioOffsetMs: boundary.audioOffsetMs + durationOffset,
|
|
527
|
+
chunkAudioOffsetMs: boundary.chunkAudioOffsetMs ?? boundary.audioOffsetMs,
|
|
528
|
+
...(boundary.chunkIndex === undefined ? { chunkIndex: resultIndex } : {}),
|
|
529
|
+
...(boundary.sourceNodePath ? { sourceNodePath: [...boundary.sourceNodePath] } : {}),
|
|
530
|
+
...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
|
|
170
531
|
...(textRange ? { textRange: { ...textRange } } : {}),
|
|
171
532
|
...(requestId ? { requestId } : {}),
|
|
172
533
|
});
|
|
173
534
|
}
|
|
174
535
|
for (const viseme of result.visemes ?? []) {
|
|
175
536
|
const textRange = viseme.textRange ?? result.textRange;
|
|
537
|
+
const originalTextRange = viseme.originalTextRange ?? textRange;
|
|
176
538
|
const requestId = viseme.requestId ?? result.requestId;
|
|
177
539
|
visemes.push({
|
|
178
540
|
...viseme,
|
|
179
541
|
audioOffsetMs: viseme.audioOffsetMs + durationOffset,
|
|
542
|
+
chunkAudioOffsetMs: viseme.chunkAudioOffsetMs ?? viseme.audioOffsetMs,
|
|
543
|
+
...(viseme.chunkIndex === undefined ? { chunkIndex: resultIndex } : {}),
|
|
544
|
+
...(viseme.sourceNodePath ? { sourceNodePath: [...viseme.sourceNodePath] } : {}),
|
|
545
|
+
...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
|
|
180
546
|
...(textRange ? { textRange: { ...textRange } } : {}),
|
|
181
547
|
...(requestId ? { requestId } : {}),
|
|
182
548
|
});
|
|
183
549
|
}
|
|
184
550
|
for (const bookmark of result.bookmarks ?? []) {
|
|
185
551
|
const textRange = bookmark.textRange ?? result.textRange;
|
|
552
|
+
const originalTextRange = bookmark.originalTextRange ?? textRange;
|
|
186
553
|
const requestId = bookmark.requestId ?? result.requestId;
|
|
187
554
|
bookmarks.push({
|
|
188
555
|
...bookmark,
|
|
189
556
|
audioOffsetMs: bookmark.audioOffsetMs + durationOffset,
|
|
557
|
+
chunkAudioOffsetMs: bookmark.chunkAudioOffsetMs ?? bookmark.audioOffsetMs,
|
|
558
|
+
...(bookmark.chunkIndex === undefined ? { chunkIndex: resultIndex } : {}),
|
|
559
|
+
...(bookmark.sourceNodePath ? { sourceNodePath: [...bookmark.sourceNodePath] } : {}),
|
|
560
|
+
...(originalTextRange ? { originalTextRange: { ...originalTextRange } } : {}),
|
|
190
561
|
...(textRange ? { textRange: { ...textRange } } : {}),
|
|
191
562
|
...(requestId ? { requestId } : {}),
|
|
192
563
|
});
|
|
@@ -195,8 +566,9 @@ export function mergeSynthesisResults(results: readonly SsmlSynthesisResult[]):
|
|
|
195
566
|
}
|
|
196
567
|
|
|
197
568
|
return {
|
|
198
|
-
audioData
|
|
569
|
+
audioData,
|
|
199
570
|
durationMs: durationOffset,
|
|
571
|
+
mimeType: resolveMimeType(format),
|
|
200
572
|
...(boundaries.length > 0 ? { boundaries, wordBoundary: boundaries, wordBoundaries: boundaries } : {}),
|
|
201
573
|
...(visemes.length > 0 ? { visemes } : {}),
|
|
202
574
|
...(bookmarks.length > 0 ? { bookmarks } : {}),
|
|
@@ -205,6 +577,43 @@ export function mergeSynthesisResults(results: readonly SsmlSynthesisResult[]):
|
|
|
205
577
|
};
|
|
206
578
|
}
|
|
207
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
|
+
|
|
208
617
|
/** Backward-compatible audio-only synthesis helper. */
|
|
209
618
|
export async function synthesizeSpeech(ssml: string, config: TtsConfig): Promise<ArrayBuffer> {
|
|
210
619
|
return (await synthesizeSsml(ssml, config)).audioData;
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { SsmlSourceMarker, SsmlSourceTextSegment, SsmlTextRange } from "@ssml-builder-js/ssml-core";
|
|
2
|
+
import type { AzureTtsOutputFormat } from "./outputFormats.ts";
|
|
3
|
+
|
|
1
4
|
export interface TtsConfig {
|
|
2
5
|
signal?: AbortSignal;
|
|
3
6
|
timeoutMs?: number;
|
|
@@ -7,15 +10,31 @@ export interface TtsConfig {
|
|
|
7
10
|
outputFormat?: string;
|
|
8
11
|
/** Original plain-text range represented by this synthesis request. */
|
|
9
12
|
sourceTextRange?: { start: number; end: number };
|
|
10
|
-
/** Reports
|
|
11
|
-
onProgress?: (event:
|
|
13
|
+
/** Reports chunk lifecycle events when using chunk synthesis. */
|
|
14
|
+
onProgress?: (event: SynthesisProgressEvent) => void;
|
|
15
|
+
/** Metadata used to map synchronization events back to the source document. */
|
|
16
|
+
chunkIndex?: number;
|
|
17
|
+
sourceNodePath?: string[];
|
|
18
|
+
/** Exact source text segments used to map individual Azure events. */
|
|
19
|
+
sourceTextSegments?: SsmlSourceTextSegment[];
|
|
20
|
+
sourceMarkers?: SsmlSourceMarker[];
|
|
12
21
|
}
|
|
13
22
|
|
|
23
|
+
export type SynthesisChunkStatus = "pending" | "synthesizing" | "success" | "failed";
|
|
24
|
+
|
|
14
25
|
export interface SsmlSynthesisBoundary {
|
|
15
26
|
text: string;
|
|
16
27
|
audioOffsetMs: number;
|
|
17
28
|
durationMs: number;
|
|
18
29
|
textRange?: { start: number; end: number };
|
|
30
|
+
/** Chunk that produced this event. */
|
|
31
|
+
chunkIndex?: number;
|
|
32
|
+
/** Path of the source SSML node, when available. */
|
|
33
|
+
sourceNodePath?: string[];
|
|
34
|
+
/** Original text range represented by this event. */
|
|
35
|
+
originalTextRange?: SsmlTextRange;
|
|
36
|
+
/** Audio offset within the originating chunk before merge. */
|
|
37
|
+
chunkAudioOffsetMs?: number;
|
|
19
38
|
requestId?: string;
|
|
20
39
|
}
|
|
21
40
|
|
|
@@ -23,6 +42,10 @@ export interface SsmlSynthesisViseme {
|
|
|
23
42
|
visemeId: number;
|
|
24
43
|
audioOffsetMs: number;
|
|
25
44
|
textRange?: { start: number; end: number };
|
|
45
|
+
chunkIndex?: number;
|
|
46
|
+
sourceNodePath?: string[];
|
|
47
|
+
originalTextRange?: SsmlTextRange;
|
|
48
|
+
chunkAudioOffsetMs?: number;
|
|
26
49
|
requestId?: string;
|
|
27
50
|
}
|
|
28
51
|
|
|
@@ -30,6 +53,10 @@ export interface SsmlSynthesisBookmark {
|
|
|
30
53
|
name: string;
|
|
31
54
|
audioOffsetMs: number;
|
|
32
55
|
textRange?: { start: number; end: number };
|
|
56
|
+
chunkIndex?: number;
|
|
57
|
+
sourceNodePath?: string[];
|
|
58
|
+
originalTextRange?: SsmlTextRange;
|
|
59
|
+
chunkAudioOffsetMs?: number;
|
|
33
60
|
requestId?: string;
|
|
34
61
|
}
|
|
35
62
|
|
|
@@ -48,21 +75,40 @@ export interface SsmlSynthesisResult {
|
|
|
48
75
|
requestId?: string;
|
|
49
76
|
/** Original plain-text range represented by the result. */
|
|
50
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;
|
|
51
84
|
}
|
|
52
85
|
|
|
53
86
|
export interface SsmlSynthesisChunk {
|
|
54
87
|
ssml: string;
|
|
55
88
|
originalTextRange?: { start: number; end: number };
|
|
89
|
+
sourceNodePath?: string[];
|
|
90
|
+
sourceTextSegments?: SsmlSourceTextSegment[];
|
|
91
|
+
sourceMarkers?: SsmlSourceMarker[];
|
|
56
92
|
}
|
|
57
93
|
|
|
58
94
|
export interface SynthesizeChunksOptions {
|
|
59
95
|
onProgress?: (event: SynthesisProgressEvent) => void;
|
|
96
|
+
outputFormat?: AzureTtsOutputFormat | string;
|
|
97
|
+
signal?: AbortSignal;
|
|
98
|
+
timeoutMs?: number;
|
|
99
|
+
sourceNodePath?: string[];
|
|
60
100
|
}
|
|
61
101
|
|
|
62
102
|
export interface SynthesisProgressEvent {
|
|
103
|
+
/** 1-based completed chunk count retained for backward compatibility. */
|
|
63
104
|
currentChunk: number;
|
|
64
105
|
totalChunks: number;
|
|
65
106
|
percent: number;
|
|
107
|
+
chunkIndex: number;
|
|
108
|
+
originalTextRange?: SsmlTextRange;
|
|
109
|
+
status: SynthesisChunkStatus;
|
|
110
|
+
durationMs: number;
|
|
111
|
+
error?: unknown;
|
|
66
112
|
}
|
|
67
113
|
|
|
68
114
|
export interface AzureTtsLogger {
|