@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/src/synthesis.ts CHANGED
@@ -1,10 +1,36 @@
1
1
  import * as SpeechSDK from "microsoft-cognitiveservices-speech-sdk";
2
- import { createSpeechSdkError, UnsupportedMergeFormatError } from "./errors.ts";
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 { SsmlSynthesisChunk, SsmlSynthesisResult, SynthesisProgressEvent, TtsConfig } from "./types.ts";
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[], format: string): ArrayBuffer {
158
- if (isWavFormat(format)) return mergeWavBuffers(buffers);
159
- if (isMp3Format(format)) {
160
- const parts = buffers.map(stripMp3Tags);
161
- const output = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0));
162
- let offset = 0;
163
- for (const part of parts) {
164
- output.set(part, offset);
165
- offset += part.byteLength;
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
- return output.buffer;
168
- }
169
- if (isRawFormat(format)) {
170
- const output = new Uint8Array(buffers.reduce((total, buffer) => total + buffer.byteLength, 0));
171
- let offset = 0;
172
- for (const buffer of buffers) {
173
- output.set(new Uint8Array(buffer), offset);
174
- offset += buffer.byteLength;
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
- return output.buffer;
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 createSpeechSdkError("Speech synthesis was cancelled.");
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(createSpeechSdkError(error));
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
- visemes.push({ visemeId: event.visemeId, audioOffsetMs: ticksToMilliseconds(event.audioOffset) });
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
- bookmarks.push({ name: event.text, audioOffsetMs: ticksToMilliseconds(event.audioOffset) });
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 ? { originalTextRange: { ...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("Speech synthesis was cancelled.");
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 ? { sourceNodePath: 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, config.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3");
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
- export function mergeSynthesisResults(results: readonly SsmlSynthesisResult[], format?: string): SsmlSynthesisResult {
370
- const audioData = format
371
- ? new Uint8Array(
372
- mergeAudioBuffers(
373
- results.map((result) => result.audioData),
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: results.indexOf(result) } : {}),
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: results.indexOf(result) } : {}),
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: results.indexOf(result) } : {}),
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: audioData.buffer,
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 {
@@ -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
- audioData: audio([1, 2]),
11
- durationMs: 100,
12
- boundaries: [{ text: "one", audioOffsetMs: 20, durationMs: 30, textRange: { start: 0, end: 3 }, requestId: "a" }],
13
- visemes: [{ visemeId: 1, audioOffsetMs: 40 }],
14
- bookmarks: [{ name: "first", audioOffsetMs: 50 }],
15
- },
16
- {
17
- audioData: audio([3, 4, 5]),
18
- durationMs: 250,
19
- boundaries: [{ text: "two", audioOffsetMs: 10, durationMs: 20, textRange: { start: 3, end: 6 }, requestId: "b" }],
20
- visemes: [{ visemeId: 2, audioOffsetMs: 15 }],
21
- bookmarks: [{ name: "second", audioOffsetMs: 25 }],
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
- [...new Uint8Array(mergeAudioBuffers([first.buffer, second.buffer], "audio-16khz-128kbitrate-mono-mp3"))],
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
  });