@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/dist/index.mjs CHANGED
@@ -10,6 +10,7 @@ var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "
10
10
  var AzureTtsError = class extends Error {
11
11
  constructor(status, statusText, responseBody, requestId) {
12
12
  super(`Azure TTS request failed: ${status} ${statusText}`);
13
+ this.kind = "azure-api-error";
13
14
  this.name = "AzureTtsError";
14
15
  this.status = status;
15
16
  this.statusText = statusText;
@@ -25,6 +26,44 @@ var AzureTtsSdkError = class extends AzureTtsError {
25
26
  this.errorDetails = errorDetails;
26
27
  }
27
28
  };
29
+ var SynthesisCancelledError = class extends Error {
30
+ constructor(message = "Speech synthesis was cancelled.") {
31
+ super(message);
32
+ this.kind = "cancelled";
33
+ this.name = "SynthesisCancelledError";
34
+ }
35
+ };
36
+ var SynthesisTimeoutError = class extends Error {
37
+ constructor(message) {
38
+ super(message);
39
+ this.kind = "timeout";
40
+ this.name = "SynthesisTimeoutError";
41
+ }
42
+ };
43
+ var MergeError = class extends Error {
44
+ constructor(message, cause) {
45
+ super(message);
46
+ this.kind = "merge-error";
47
+ this.name = "MergeError";
48
+ this.cause = cause;
49
+ }
50
+ };
51
+ var UnsupportedMergeFormatError = class extends Error {
52
+ constructor(format) {
53
+ super(`Audio format "${format}" cannot be safely concatenated; container re-multiplexing is required.`);
54
+ this.kind = "unsupported-format-error";
55
+ this.name = "UnsupportedMergeFormatError";
56
+ this.format = format;
57
+ }
58
+ };
59
+ function toSynthesisError(error) {
60
+ if (error instanceof AzureTtsError || error instanceof MergeError || error instanceof UnsupportedMergeFormatError || error instanceof SynthesisCancelledError || error instanceof SynthesisTimeoutError)
61
+ return error;
62
+ const message = error instanceof Error ? error.message : String(error);
63
+ if (/cancel|abort/i.test(message)) return new SynthesisCancelledError(message);
64
+ if (/tim(?:e|ed) ?out/i.test(message)) return new SynthesisTimeoutError(message);
65
+ return createSpeechSdkError(error);
66
+ }
28
67
  function createSpeechSdkError(error) {
29
68
  const message = error instanceof Error ? error.message : String(error);
30
69
  return new AzureTtsSdkError(message);
@@ -32,9 +71,7 @@ function createSpeechSdkError(error) {
32
71
 
33
72
  // src/synthesis.ts
34
73
  import * as SpeechSDK2 from "microsoft-cognitiveservices-speech-sdk";
35
-
36
- // src/speechConfig.ts
37
- import { SpeechConfig } from "microsoft-cognitiveservices-speech-sdk";
74
+ import { getSsmlSourceMap } from "@ssml-builder-js/ssml-core";
38
75
 
39
76
  // src/outputFormats.ts
40
77
  import * as SpeechSDK from "microsoft-cognitiveservices-speech-sdk";
@@ -80,6 +117,14 @@ var OUTPUT_FORMATS = {
80
117
  "amr-wb-16000hz": SpeechSDK.SpeechSynthesisOutputFormat.AmrWb16000Hz,
81
118
  "g722-16khz-64kbps": SpeechSDK.SpeechSynthesisOutputFormat.G72216Khz64Kbps
82
119
  };
120
+ function resolveMimeType(outputFormat) {
121
+ if (/(?:wav|wave|riff)/i.test(outputFormat)) return "audio/wav";
122
+ if (/(?:mp3|mpeg)/i.test(outputFormat)) return "audio/mpeg";
123
+ if (/ogg/i.test(outputFormat)) return "audio/ogg";
124
+ if (/webm/i.test(outputFormat)) return "audio/webm";
125
+ if (/raw/i.test(outputFormat)) return "audio/L16";
126
+ return "application/octet-stream";
127
+ }
83
128
  function resolveOutputFormat(outputFormat) {
84
129
  const resolvedFormat = OUTPUT_FORMATS[outputFormat];
85
130
  if (resolvedFormat === void 0) {
@@ -89,6 +134,7 @@ function resolveOutputFormat(outputFormat) {
89
134
  }
90
135
 
91
136
  // src/speechConfig.ts
137
+ import { SpeechConfig } from "microsoft-cognitiveservices-speech-sdk";
92
138
  function resolveEndpoint(config) {
93
139
  const endpoint = config.endpoint?.trim() || "https://{region}.tts.speech.microsoft.com/cognitiveservices/v1";
94
140
  return endpoint.replace(/\{region\}/g, encodeURIComponent(config.region));
@@ -102,6 +148,156 @@ function createSpeechConfig(config) {
102
148
  }
103
149
 
104
150
  // src/synthesis.ts
151
+ function ascii(bytes, offset, value) {
152
+ return [...value].every((character, index) => bytes[offset + index] === character.charCodeAt(0));
153
+ }
154
+ function readUint32(bytes, offset) {
155
+ return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(offset, true);
156
+ }
157
+ function parseWav(buffer) {
158
+ const bytes = new Uint8Array(buffer);
159
+ if (bytes.byteLength < 12 || !ascii(bytes, 0, "RIFF") || !ascii(bytes, 8, "WAVE")) {
160
+ throw new Error("Invalid WAV/RIFF audio buffer.");
161
+ }
162
+ const chunks = [];
163
+ const dataParts = [];
164
+ let format;
165
+ let offset = 12;
166
+ while (offset < bytes.byteLength) {
167
+ if (offset + 8 > bytes.byteLength) throw new Error("Invalid WAV chunk header.");
168
+ const id = String.fromCharCode(...bytes.slice(offset, offset + 4));
169
+ const size = readUint32(bytes, offset + 4);
170
+ const dataStart = offset + 8;
171
+ const dataEnd = dataStart + size;
172
+ if (dataEnd > bytes.byteLength) throw new Error(`WAV chunk "${id}" exceeds the audio buffer.`);
173
+ const data2 = bytes.slice(dataStart, dataEnd);
174
+ chunks.push({ id, data: data2 });
175
+ if (id === "fmt ") format ?? (format = data2);
176
+ if (id === "data") dataParts.push(data2);
177
+ offset = dataEnd + (size & 1);
178
+ if (offset > bytes.byteLength) throw new Error("Invalid WAV chunk padding.");
179
+ }
180
+ if (!format || dataParts.length === 0) throw new Error("WAV audio must contain fmt and data chunks.");
181
+ const dataLength = dataParts.reduce((total, part) => total + part.byteLength, 0);
182
+ const data = new Uint8Array(dataLength);
183
+ let dataOffset = 0;
184
+ for (const part of dataParts) {
185
+ data.set(part, dataOffset);
186
+ dataOffset += part.byteLength;
187
+ }
188
+ return { chunks, data, format };
189
+ }
190
+ function writeUint32(target, offset, value) {
191
+ new DataView(target.buffer).setUint32(offset, value, true);
192
+ }
193
+ function writeChunk(target, offset, id, data) {
194
+ for (let index = 0; index < 4; index += 1) target[offset + index] = id.charCodeAt(index) ?? 0;
195
+ writeUint32(target, offset + 4, data.byteLength);
196
+ target.set(data, offset + 8);
197
+ const end = offset + 8 + data.byteLength;
198
+ if (data.byteLength & 1) target[end] = 0;
199
+ return end + (data.byteLength & 1);
200
+ }
201
+ function mergeWavBuffers(buffers) {
202
+ if (buffers.length === 0) return new ArrayBuffer(0);
203
+ const parsed = buffers.map(parseWav);
204
+ const first = parsed[0];
205
+ if (!first) throw new Error("At least one WAV buffer is required.");
206
+ if (parsed.some(
207
+ (item) => item.format.length !== first.format.length || item.format.some((value, i) => value !== first.format[i])
208
+ ))
209
+ throw new Error("WAV buffers have incompatible fmt chunks.");
210
+ const dataLength = parsed.reduce((total, item) => total + item.data.byteLength, 0);
211
+ const nonDataLength = first.chunks.reduce(
212
+ (total, chunk) => chunk.id === "data" ? total : total + 8 + chunk.data.byteLength + (chunk.data.byteLength & 1),
213
+ 0
214
+ );
215
+ const outputLength = 12 + nonDataLength + 8 + dataLength + (dataLength & 1);
216
+ if (outputLength - 8 > 4294967295) throw new RangeError("Merged WAV exceeds the RIFF format size limit.");
217
+ const output = new Uint8Array(outputLength);
218
+ output.set(Uint8Array.from([82, 73, 70, 70]), 0);
219
+ writeUint32(output, 4, outputLength - 8);
220
+ output.set(Uint8Array.from([87, 65, 86, 69]), 8);
221
+ let outputOffset = 12;
222
+ let dataWritten = false;
223
+ for (const chunk of first.chunks) {
224
+ if (chunk.id === "data") {
225
+ if (dataWritten) continue;
226
+ const data = new Uint8Array(dataLength);
227
+ let dataOffset = 0;
228
+ for (const item of parsed) {
229
+ data.set(item.data, dataOffset);
230
+ dataOffset += item.data.byteLength;
231
+ }
232
+ outputOffset = writeChunk(output, outputOffset, "data", data);
233
+ dataWritten = true;
234
+ } else {
235
+ outputOffset = writeChunk(output, outputOffset, chunk.id, chunk.data);
236
+ }
237
+ }
238
+ if (!dataWritten) throw new Error("WAV audio must contain a data chunk.");
239
+ return output.buffer;
240
+ }
241
+ function skipId3v2(bytes) {
242
+ if (!ascii(bytes, 0, "ID3") || bytes.byteLength < 10) return 0;
243
+ const size = [bytes[6], bytes[7], bytes[8], bytes[9]].reduce((total, value) => total << 7 | value & 127, 0);
244
+ const hasFooter = (bytes[5] & 16) !== 0;
245
+ return Math.min(bytes.byteLength, 10 + size + (hasFooter ? 10 : 0));
246
+ }
247
+ function stripMp3Tags(buffer) {
248
+ const bytes = new Uint8Array(buffer);
249
+ const start = skipId3v2(bytes);
250
+ const end = bytes.byteLength >= 128 && ascii(bytes, bytes.byteLength - 128, "TAG") ? bytes.byteLength - 128 : bytes.byteLength;
251
+ return bytes.slice(Math.min(start, end), end);
252
+ }
253
+ function isMp3Format(format) {
254
+ return /(?:mp3|mpeg)/i.test(format);
255
+ }
256
+ function isWavFormat(format) {
257
+ return /(?:wav|wave|riff)/i.test(format);
258
+ }
259
+ function isRawFormat(format) {
260
+ return /^raw(?:-|$)/i.test(format);
261
+ }
262
+ function resolveMergeAudioFormat(format) {
263
+ if (isWavFormat(format)) return "wav";
264
+ if (isMp3Format(format)) return "mp3";
265
+ if (isRawFormat(format)) return "raw";
266
+ return void 0;
267
+ }
268
+ function canMergeAudioFormat(format) {
269
+ return resolveMergeAudioFormat(format) !== void 0;
270
+ }
271
+ function mergeAudioBuffers(buffers, options) {
272
+ const format = typeof options === "string" ? options : options?.format;
273
+ if (!format) throw new UnsupportedMergeFormatError("");
274
+ try {
275
+ if (isWavFormat(format)) return mergeWavBuffers(buffers);
276
+ if (isMp3Format(format)) {
277
+ const parts = buffers.map(stripMp3Tags);
278
+ const output = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0));
279
+ let offset = 0;
280
+ for (const part of parts) {
281
+ output.set(part, offset);
282
+ offset += part.byteLength;
283
+ }
284
+ return output.buffer;
285
+ }
286
+ if (isRawFormat(format)) {
287
+ const output = new Uint8Array(buffers.reduce((total, buffer) => total + buffer.byteLength, 0));
288
+ let offset = 0;
289
+ for (const buffer of buffers) {
290
+ output.set(new Uint8Array(buffer), offset);
291
+ offset += buffer.byteLength;
292
+ }
293
+ return output.buffer;
294
+ }
295
+ throw new UnsupportedMergeFormatError(format);
296
+ } catch (error) {
297
+ if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
298
+ throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
299
+ }
300
+ }
105
301
  function closeSpeechResources(speechConfig, synthesizer) {
106
302
  try {
107
303
  synthesizer.close();
@@ -115,7 +311,7 @@ function closeSpeechResources(speechConfig, synthesizer) {
115
311
  var ticksToMilliseconds = (ticks) => Math.max(0, ticks) / 1e4;
116
312
  async function synthesizeSsml(ssml, config) {
117
313
  if (config.signal?.aborted) {
118
- throw createSpeechSdkError("Speech synthesis was cancelled.");
314
+ throw new SynthesisCancelledError();
119
315
  }
120
316
  const speechConfig = createSpeechConfig(config);
121
317
  const synthesizer = new SpeechSDK2.SpeechSynthesizer(speechConfig, null);
@@ -138,23 +334,94 @@ async function synthesizeSsml(ssml, config) {
138
334
  settled = true;
139
335
  cleanup();
140
336
  closeResources();
141
- reject(createSpeechSdkError(error));
337
+ reject(toSynthesisError(error));
142
338
  };
143
339
  const boundaries = [];
144
340
  const visemes = [];
145
341
  const bookmarks = [];
342
+ let sourceEventCursor = 0;
343
+ let generatedSourceMap;
344
+ if (!config.sourceTextSegments && !config.sourceMarkers) {
345
+ try {
346
+ generatedSourceMap = getSsmlSourceMap(ssml);
347
+ } catch {
348
+ generatedSourceMap = void 0;
349
+ }
350
+ }
351
+ const sourceBaseOffset = config.sourceTextRange?.start ?? 0;
352
+ const sourceSegments = config.sourceTextSegments ?? generatedSourceMap?.segments.map((segment) => ({
353
+ ...segment,
354
+ range: {
355
+ start: segment.range.start + sourceBaseOffset,
356
+ end: segment.range.end + sourceBaseOffset
357
+ },
358
+ sourceNodePath: [...segment.sourceNodePath]
359
+ })) ?? [];
360
+ const sourceMarkers = config.sourceMarkers ?? generatedSourceMap?.markers.map((marker) => ({
361
+ ...marker,
362
+ originalTextRange: {
363
+ start: marker.originalTextRange.start + sourceBaseOffset,
364
+ end: marker.originalTextRange.end + sourceBaseOffset
365
+ },
366
+ sourceNodePath: [...marker.sourceNodePath]
367
+ })) ?? [];
368
+ const sourceText = sourceSegments.map((segment) => segment.text).join("");
369
+ const mapSourceEvent = (text, offsetHint, markerName) => {
370
+ const marker = markerName ? sourceMarkers.find((candidate) => candidate.name === markerName) : void 0;
371
+ if (marker) {
372
+ return {
373
+ originalTextRange: { ...marker.originalTextRange },
374
+ sourceNodePath: [...marker.sourceNodePath],
375
+ textRange: { ...marker.originalTextRange }
376
+ };
377
+ }
378
+ if (sourceSegments.length === 0 && !config.sourceTextRange && !config.sourceNodePath) return {};
379
+ const value = text ?? "";
380
+ let localStart = Number.isFinite(offsetHint) && (offsetHint ?? 0) >= 0 ? offsetHint : -1;
381
+ if (value && localStart >= 0 && sourceText.slice(localStart, localStart + value.length) !== value)
382
+ localStart = -1;
383
+ if (localStart < 0 || localStart > sourceText.length) {
384
+ localStart = value ? sourceText.indexOf(value, sourceEventCursor) : sourceEventCursor;
385
+ if (localStart < 0) localStart = value ? sourceText.indexOf(value) : sourceEventCursor;
386
+ }
387
+ localStart = Math.max(0, localStart);
388
+ const localEnd = Math.min(sourceText.length, localStart + value.length);
389
+ sourceEventCursor = Math.max(sourceEventCursor, localEnd);
390
+ const baseStart = config.sourceTextRange?.start ?? sourceSegments[0]?.range.start ?? 0;
391
+ const fallbackRange = { start: baseStart + localStart, end: baseStart + localEnd };
392
+ const segment = sourceSegments.find(({ range }) => range.start <= fallbackRange.start && range.end > fallbackRange.start) ?? sourceSegments.find(({ range }) => range.end > fallbackRange.start) ?? (value.length === 0 ? sourceSegments.find(({ range }) => range.start <= fallbackRange.start && range.end >= fallbackRange.start) : void 0);
393
+ return {
394
+ originalTextRange: { ...fallbackRange },
395
+ textRange: { ...fallbackRange },
396
+ ...segment ? { sourceNodePath: [...segment.sourceNodePath] } : config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {}
397
+ };
398
+ };
146
399
  synthesizer.wordBoundary = (_sender, event) => {
147
400
  boundaries.push({
148
401
  text: event.text,
149
402
  audioOffsetMs: ticksToMilliseconds(event.audioOffset),
150
- durationMs: ticksToMilliseconds(event.duration)
403
+ durationMs: ticksToMilliseconds(event.duration),
404
+ ...mapSourceEvent(
405
+ event.text,
406
+ event.textOffset
407
+ )
151
408
  });
152
409
  };
153
410
  synthesizer.visemeReceived = (_sender, event) => {
154
- visemes.push({ visemeId: event.visemeId, audioOffsetMs: ticksToMilliseconds(event.audioOffset) });
411
+ const eventWithOffset = event;
412
+ visemes.push({
413
+ visemeId: event.visemeId,
414
+ audioOffsetMs: ticksToMilliseconds(event.audioOffset),
415
+ ...mapSourceEvent(void 0, eventWithOffset.textOffset)
416
+ });
155
417
  };
156
418
  synthesizer.bookmarkReached = (_sender, event) => {
157
- bookmarks.push({ name: event.text, audioOffsetMs: ticksToMilliseconds(event.audioOffset) });
419
+ const eventWithOffset = event;
420
+ bookmarks.push({
421
+ name: event.text,
422
+ audioOffsetMs: ticksToMilliseconds(event.audioOffset),
423
+ ...mapSourceEvent(void 0, eventWithOffset.textOffset, event.text)
424
+ });
158
425
  };
159
426
  const cb = (result) => {
160
427
  if (settled) return;
@@ -177,7 +444,10 @@ async function synthesizeSsml(ssml, config) {
177
444
  const requestId = result.resultId;
178
445
  const addSourceMetadata = (event) => ({
179
446
  ...event,
180
- ...config.sourceTextRange ? { textRange: { ...config.sourceTextRange } } : {},
447
+ ...config.sourceTextRange && !("textRange" in event) ? { textRange: { ...config.sourceTextRange } } : {},
448
+ ...config.sourceTextRange && !("originalTextRange" in event) ? { originalTextRange: { ...config.sourceTextRange } } : {},
449
+ ...config.chunkIndex !== void 0 ? { chunkIndex: config.chunkIndex } : {},
450
+ ...config.sourceNodePath ? { sourceNodePath: [...config.sourceNodePath] } : {},
181
451
  ...requestId ? { requestId } : {}
182
452
  });
183
453
  const sourceBoundaries = boundaries.map((boundary) => addSourceMetadata(boundary));
@@ -195,12 +465,12 @@ async function synthesizeSsml(ssml, config) {
195
465
  };
196
466
  try {
197
467
  if (config.signal) {
198
- abortHandler = () => rejectWithError("Speech synthesis was cancelled.");
468
+ abortHandler = () => rejectWithError(new SynthesisCancelledError());
199
469
  config.signal.addEventListener("abort", abortHandler, { once: true });
200
470
  }
201
471
  if (config.timeoutMs !== void 0 && config.timeoutMs > 0) {
202
472
  timeout = setTimeout(
203
- () => rejectWithError(`Speech synthesis timed out after ${config.timeoutMs} ms.`),
473
+ () => rejectWithError(new SynthesisTimeoutError(`Speech synthesis timed out after ${config.timeoutMs} ms.`)),
204
474
  config.timeoutMs
205
475
  );
206
476
  }
@@ -213,60 +483,117 @@ async function synthesizeSsml(ssml, config) {
213
483
  async function synthesizeSsmlChunks(chunks, config) {
214
484
  const results = [];
215
485
  const totalChunks = chunks.length;
486
+ const report = (event) => config.onProgress?.(event);
216
487
  for (const [index, chunk] of chunks.entries()) {
217
488
  const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
218
- const result = await synthesizeSsml(input.ssml, {
219
- ...config,
220
- ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
221
- onProgress: void 0
489
+ report({
490
+ currentChunk: index,
491
+ totalChunks,
492
+ percent: totalChunks === 0 ? 100 : Math.round(index / totalChunks * 100),
493
+ chunkIndex: index,
494
+ originalTextRange: input.originalTextRange,
495
+ status: "pending",
496
+ durationMs: 0
222
497
  });
223
- results.push(result);
224
- config.onProgress?.({
225
- currentChunk: index + 1,
498
+ }
499
+ for (const [index, chunk] of chunks.entries()) {
500
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
501
+ report({
502
+ currentChunk: index,
226
503
  totalChunks,
227
- percent: totalChunks === 0 ? 100 : Math.round((index + 1) / totalChunks * 100)
504
+ percent: totalChunks === 0 ? 100 : Math.round(index / totalChunks * 100),
505
+ chunkIndex: index,
506
+ originalTextRange: input.originalTextRange,
507
+ status: "synthesizing",
508
+ durationMs: 0
228
509
  });
510
+ const startedAt = Date.now();
511
+ try {
512
+ const result = await synthesizeSsml(input.ssml, {
513
+ ...config,
514
+ ...input.originalTextRange ? { sourceTextRange: input.originalTextRange } : {},
515
+ ...input.sourceNodePath ?? config.sourceNodePath ? { sourceNodePath: [...input.sourceNodePath ?? config.sourceNodePath ?? []] } : {},
516
+ ...input.sourceTextSegments ? { sourceTextSegments: input.sourceTextSegments } : {},
517
+ ...input.sourceMarkers ? { sourceMarkers: input.sourceMarkers } : {},
518
+ chunkIndex: index,
519
+ onProgress: void 0
520
+ });
521
+ results.push(result);
522
+ report({
523
+ currentChunk: index + 1,
524
+ totalChunks,
525
+ percent: totalChunks === 0 ? 100 : Math.round((index + 1) / totalChunks * 100),
526
+ chunkIndex: index,
527
+ originalTextRange: input.originalTextRange,
528
+ status: "success",
529
+ durationMs: Date.now() - startedAt
530
+ });
531
+ } catch (error) {
532
+ report({
533
+ currentChunk: index,
534
+ totalChunks,
535
+ percent: totalChunks === 0 ? 100 : Math.round(index / totalChunks * 100),
536
+ chunkIndex: index,
537
+ originalTextRange: input.originalTextRange,
538
+ status: "failed",
539
+ durationMs: Date.now() - startedAt,
540
+ error
541
+ });
542
+ throw error;
543
+ }
229
544
  }
230
- return mergeSynthesisResults(results);
545
+ return mergeSynthesisResults(results, {
546
+ format: config.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3"
547
+ });
231
548
  }
232
- function mergeSynthesisResults(results) {
233
- const audioLength = results.reduce((total, result) => total + result.audioData.byteLength, 0);
234
- const audioData = new Uint8Array(audioLength);
549
+ function createMergedResult(results, audioData, format) {
235
550
  const boundaries = [];
236
551
  const visemes = [];
237
552
  const bookmarks = [];
238
- let byteOffset = 0;
239
553
  let durationOffset = 0;
240
- for (const result of results) {
241
- audioData.set(new Uint8Array(result.audioData), byteOffset);
242
- byteOffset += result.audioData.byteLength;
554
+ for (const [resultIndex, result] of results.entries()) {
243
555
  const chunkBoundaries = result.boundaries && result.boundaries.length > 0 ? result.boundaries : result.wordBoundary ?? result.wordBoundaries ?? [];
244
556
  for (const boundary of chunkBoundaries) {
245
557
  const textRange = boundary.textRange ?? result.textRange;
558
+ const originalTextRange = boundary.originalTextRange ?? textRange;
246
559
  const requestId = boundary.requestId ?? result.requestId;
247
560
  boundaries.push({
248
561
  ...boundary,
249
562
  audioOffsetMs: boundary.audioOffsetMs + durationOffset,
563
+ chunkAudioOffsetMs: boundary.chunkAudioOffsetMs ?? boundary.audioOffsetMs,
564
+ ...boundary.chunkIndex === void 0 ? { chunkIndex: resultIndex } : {},
565
+ ...boundary.sourceNodePath ? { sourceNodePath: [...boundary.sourceNodePath] } : {},
566
+ ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
250
567
  ...textRange ? { textRange: { ...textRange } } : {},
251
568
  ...requestId ? { requestId } : {}
252
569
  });
253
570
  }
254
571
  for (const viseme of result.visemes ?? []) {
255
572
  const textRange = viseme.textRange ?? result.textRange;
573
+ const originalTextRange = viseme.originalTextRange ?? textRange;
256
574
  const requestId = viseme.requestId ?? result.requestId;
257
575
  visemes.push({
258
576
  ...viseme,
259
577
  audioOffsetMs: viseme.audioOffsetMs + durationOffset,
578
+ chunkAudioOffsetMs: viseme.chunkAudioOffsetMs ?? viseme.audioOffsetMs,
579
+ ...viseme.chunkIndex === void 0 ? { chunkIndex: resultIndex } : {},
580
+ ...viseme.sourceNodePath ? { sourceNodePath: [...viseme.sourceNodePath] } : {},
581
+ ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
260
582
  ...textRange ? { textRange: { ...textRange } } : {},
261
583
  ...requestId ? { requestId } : {}
262
584
  });
263
585
  }
264
586
  for (const bookmark of result.bookmarks ?? []) {
265
587
  const textRange = bookmark.textRange ?? result.textRange;
588
+ const originalTextRange = bookmark.originalTextRange ?? textRange;
266
589
  const requestId = bookmark.requestId ?? result.requestId;
267
590
  bookmarks.push({
268
591
  ...bookmark,
269
592
  audioOffsetMs: bookmark.audioOffsetMs + durationOffset,
593
+ chunkAudioOffsetMs: bookmark.chunkAudioOffsetMs ?? bookmark.audioOffsetMs,
594
+ ...bookmark.chunkIndex === void 0 ? { chunkIndex: resultIndex } : {},
595
+ ...bookmark.sourceNodePath ? { sourceNodePath: [...bookmark.sourceNodePath] } : {},
596
+ ...originalTextRange ? { originalTextRange: { ...originalTextRange } } : {},
270
597
  ...textRange ? { textRange: { ...textRange } } : {},
271
598
  ...requestId ? { requestId } : {}
272
599
  });
@@ -274,8 +601,9 @@ function mergeSynthesisResults(results) {
274
601
  durationOffset += Math.max(0, result.durationMs);
275
602
  }
276
603
  return {
277
- audioData: audioData.buffer,
604
+ audioData,
278
605
  durationMs: durationOffset,
606
+ mimeType: resolveMimeType(format),
279
607
  ...boundaries.length > 0 ? { boundaries, wordBoundary: boundaries, wordBoundaries: boundaries } : {},
280
608
  ...visemes.length > 0 ? { visemes } : {},
281
609
  ...bookmarks.length > 0 ? { bookmarks } : {},
@@ -283,35 +611,225 @@ function mergeSynthesisResults(results) {
283
611
  ...results.length === 1 && results[0]?.textRange ? { textRange: { ...results[0].textRange } } : {}
284
612
  };
285
613
  }
614
+ function mergeSynthesisResults(results, options) {
615
+ const resolvedOptions = typeof options === "string" ? { format: options } : options;
616
+ const format = resolvedOptions?.format;
617
+ if (!format) throw new UnsupportedMergeFormatError("");
618
+ const buffers = results.map((result) => result.audioData);
619
+ if (resolvedOptions.customMerger) {
620
+ return Promise.resolve().then(() => resolvedOptions.customMerger?.(buffers, format)).then((merged) => {
621
+ if (!merged) throw new MergeError("The custom audio merger returned no audio buffer.");
622
+ return createMergedResult(results, merged, format);
623
+ }).catch((error) => {
624
+ if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
625
+ throw new MergeError(`Custom audio merger failed for format "${format}".`, error);
626
+ });
627
+ }
628
+ try {
629
+ return createMergedResult(results, mergeAudioBuffers(buffers, { format }), format);
630
+ } catch (error) {
631
+ if (error instanceof UnsupportedMergeFormatError || error instanceof MergeError) throw error;
632
+ throw new MergeError(`Audio buffers could not be merged for format "${format}".`, error);
633
+ }
634
+ }
286
635
  async function synthesizeSpeech(ssml, config) {
287
636
  return (await synthesizeSsml(ssml, config)).audioData;
288
637
  }
289
638
 
290
639
  // src/safe.ts
291
640
  import { validateAzureSsml } from "@ssml-builder-js/ssml-core";
641
+ var ChunkValidationError = class extends Error {
642
+ constructor(chunkIndex, diagnostics) {
643
+ super(`SSML validation failed for chunk ${chunkIndex}; the Azure Speech API was not called.`);
644
+ this.kind = "validation-error";
645
+ this.name = "ChunkValidationError";
646
+ this.chunkIndex = chunkIndex;
647
+ this.diagnostics = diagnostics;
648
+ }
649
+ };
650
+ function failure(error) {
651
+ return { ok: false, success: false, status: error.kind, error };
652
+ }
292
653
  async function synthesizeSsmlSafe(client, ssml, options = {}) {
293
- const validationOptions = options.validation ?? options;
654
+ const validationOptions = withValidationSignal(options.validation ?? options, options.signal);
294
655
  const diagnostics = await Promise.resolve(validateAzureSsml(ssml, validationOptions));
656
+ if (options.signal?.aborted) {
657
+ const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
658
+ return failure(error);
659
+ }
295
660
  const errors = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
296
661
  if (errors.length > 0) {
662
+ return failure({
663
+ kind: "validation-error",
664
+ message: "SSML validation failed; the Azure Speech API was not called.",
665
+ diagnostics: errors
666
+ });
667
+ }
668
+ try {
297
669
  return {
298
- ok: false,
299
- success: false,
300
- status: "validation-error",
301
- error: {
302
- kind: "validation",
303
- message: "SSML validation failed; the Azure Speech API was not called.",
304
- diagnostics: errors
305
- }
670
+ ok: true,
671
+ success: true,
672
+ status: "success",
673
+ value: await client.synthesizeSsml(ssml, { signal: options.signal })
306
674
  };
675
+ } catch (error) {
676
+ const synthesisError = toSynthesisError(error);
677
+ return failure(synthesisError);
678
+ }
679
+ }
680
+ async function synthesizeSsmlChunksSafe(client, chunks, options = {}) {
681
+ const validationOptions = withValidationSignal(options.validation ?? options, options.signal);
682
+ if (options.signal?.aborted) {
683
+ const error = toSynthesisError(new Error("Speech synthesis was cancelled."));
684
+ return failure(error);
685
+ }
686
+ const pending = (index, status, error) => {
687
+ options.onProgress?.({
688
+ currentChunk: status === "success" ? index + 1 : index,
689
+ totalChunks: chunks.length,
690
+ percent: chunks.length === 0 ? 100 : Math.round((status === "success" ? index + 1 : index) / chunks.length * 100),
691
+ chunkIndex: index,
692
+ originalTextRange: typeof chunks[index] === "string" ? void 0 : chunks[index]?.originalTextRange,
693
+ status,
694
+ durationMs: 0,
695
+ ...error ? { error } : {}
696
+ });
697
+ };
698
+ chunks.forEach((_chunk, index) => {
699
+ pending(index, "pending");
700
+ });
701
+ const validations = await Promise.all(
702
+ chunks.map(async (chunk) => {
703
+ const ssml = typeof chunk === "string" ? chunk : chunk.ssml;
704
+ const sourceNodePath = typeof chunk === "string" ? options.sourceNodePath : chunk.sourceNodePath ?? options.sourceNodePath;
705
+ const diagnostics = await Promise.resolve(
706
+ validateAzureSsml(ssml, { ...validationOptions, ...sourceNodePath ? { sourceNodePath } : {} })
707
+ );
708
+ return diagnostics.filter((diagnostic) => diagnostic.severity === "error");
709
+ })
710
+ );
711
+ const firstInvalidIndex = validations.findIndex((diagnostics) => diagnostics.length > 0);
712
+ if (firstInvalidIndex >= 0) {
713
+ const error = new ChunkValidationError(firstInvalidIndex, validations[firstInvalidIndex] ?? []);
714
+ pending(firstInvalidIndex, "failed", error);
715
+ return failure(error);
307
716
  }
308
717
  try {
309
- return { ok: true, success: true, status: "success", value: await client.synthesizeSsml(ssml) };
718
+ if (client.synthesizeChunks) {
719
+ const normalizedChunks = chunks.map((chunk) => {
720
+ if (typeof chunk === "string" || chunk.sourceNodePath || !options.sourceNodePath) return chunk;
721
+ return { ...chunk, sourceNodePath: [...options.sourceNodePath] };
722
+ });
723
+ const value = await client.synthesizeChunks(normalizedChunks, {
724
+ onProgress: options.onProgress,
725
+ outputFormat: options.outputFormat,
726
+ signal: options.signal,
727
+ timeoutMs: options.timeoutMs,
728
+ sourceNodePath: options.sourceNodePath
729
+ });
730
+ return { ok: true, success: true, status: "success", value };
731
+ }
732
+ const results = [];
733
+ for (const [index, chunk] of chunks.entries()) {
734
+ const input = typeof chunk === "string" ? { ssml: chunk } : chunk;
735
+ const sourceNodePath = input.sourceNodePath;
736
+ const originalTextRange = input.originalTextRange;
737
+ pending(index, "synthesizing");
738
+ const startedAt = Date.now();
739
+ try {
740
+ const result = await client.synthesizeSsml(input.ssml, {
741
+ outputFormat: options.outputFormat,
742
+ signal: options.signal,
743
+ timeoutMs: options.timeoutMs,
744
+ sourceNodePath: input.sourceNodePath ?? options.sourceNodePath
745
+ });
746
+ results.push({
747
+ ...result,
748
+ ...input.originalTextRange ? { textRange: { ...input.originalTextRange } } : {},
749
+ ...sourceNodePath ? {
750
+ boundaries: result.boundaries?.map((event) => ({
751
+ ...event,
752
+ sourceNodePath: [...sourceNodePath],
753
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
754
+ })),
755
+ visemes: result.visemes?.map((event) => ({
756
+ ...event,
757
+ sourceNodePath: [...sourceNodePath],
758
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
759
+ })),
760
+ bookmarks: result.bookmarks?.map((event) => ({
761
+ ...event,
762
+ sourceNodePath: [...sourceNodePath],
763
+ ...event.originalTextRange ? { originalTextRange: { ...event.originalTextRange } } : input.originalTextRange ? { originalTextRange: { ...input.originalTextRange } } : {}
764
+ }))
765
+ } : {},
766
+ ...originalTextRange ? {
767
+ boundaries: result.boundaries?.map((event) => ({
768
+ ...event,
769
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
770
+ })),
771
+ wordBoundary: result.wordBoundary?.map((event) => ({
772
+ ...event,
773
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
774
+ })),
775
+ wordBoundaries: result.wordBoundaries?.map((event) => ({
776
+ ...event,
777
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
778
+ })),
779
+ visemes: result.visemes?.map((event) => ({
780
+ ...event,
781
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
782
+ })),
783
+ bookmarks: result.bookmarks?.map((event) => ({
784
+ ...event,
785
+ originalTextRange: event.originalTextRange ? { ...event.originalTextRange } : { ...originalTextRange }
786
+ }))
787
+ } : {}
788
+ });
789
+ options.onProgress?.({
790
+ currentChunk: index + 1,
791
+ totalChunks: chunks.length,
792
+ percent: chunks.length === 0 ? 100 : Math.round((index + 1) / chunks.length * 100),
793
+ chunkIndex: index,
794
+ originalTextRange: input.originalTextRange,
795
+ status: "success",
796
+ durationMs: Date.now() - startedAt
797
+ });
798
+ } catch (error) {
799
+ options.onProgress?.({
800
+ currentChunk: index,
801
+ totalChunks: chunks.length,
802
+ percent: chunks.length === 0 ? 100 : Math.round(index / chunks.length * 100),
803
+ chunkIndex: index,
804
+ originalTextRange: input.originalTextRange,
805
+ status: "failed",
806
+ durationMs: Date.now() - startedAt,
807
+ error
808
+ });
809
+ throw error;
810
+ }
811
+ }
812
+ return {
813
+ ok: true,
814
+ success: true,
815
+ status: "success",
816
+ value: mergeSynthesisResults(results, {
817
+ format: options.outputFormat ?? "audio-16khz-128kbitrate-mono-mp3"
818
+ })
819
+ };
310
820
  } catch (error) {
311
- const azureError = error instanceof AzureTtsError ? error : createSpeechSdkError(error);
312
- return { ok: false, success: false, status: "azure-api-error", error: azureError };
821
+ const synthesisError = toSynthesisError(error);
822
+ return failure(synthesisError);
313
823
  }
314
824
  }
825
+ function withValidationSignal(options, signal) {
826
+ if (!signal) return options;
827
+ return {
828
+ ...options,
829
+ urlValidatorSignal: signal,
830
+ urlValidation: { ...options.urlValidation ?? {}, signal }
831
+ };
832
+ }
315
833
 
316
834
  // src/client.ts
317
835
  var ENDPOINT_TEMPLATE = "https://{region}.tts.speech.microsoft.com/cognitiveservices/v1";
@@ -328,11 +846,21 @@ var AzureTtsClient = class {
328
846
  const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs };
329
847
  return synthesizeSpeech(ssml, config);
330
848
  }
331
- async synthesizeSsml(ssml) {
849
+ async synthesizeSsml(ssml, options = {}) {
332
850
  const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
333
851
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
334
852
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
335
- return synthesizeSsml(ssml, { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs });
853
+ return synthesizeSsml(ssml, {
854
+ endpoint,
855
+ region,
856
+ subscriptionKey,
857
+ outputFormat: options.outputFormat ?? outputFormat,
858
+ signal: options.signal ?? signal,
859
+ timeoutMs: options.timeoutMs ?? timeoutMs,
860
+ sourceNodePath: options.sourceNodePath,
861
+ sourceTextSegments: options.sourceTextSegments,
862
+ sourceMarkers: options.sourceMarkers
863
+ });
336
864
  }
337
865
  async synthesizeChunks(chunks, options = {}) {
338
866
  const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
@@ -341,15 +869,28 @@ var AzureTtsClient = class {
341
869
  endpoint,
342
870
  region,
343
871
  subscriptionKey,
344
- outputFormat,
345
- signal,
346
- timeoutMs,
872
+ outputFormat: options.outputFormat ?? outputFormat,
873
+ signal: options.signal ?? signal,
874
+ timeoutMs: options.timeoutMs ?? timeoutMs,
875
+ sourceNodePath: options.sourceNodePath,
347
876
  onProgress: options.onProgress ?? __privateGet(this, _options).onProgress
348
877
  });
349
878
  }
350
879
  async synthesizeSsmlSafe(ssml, options = {}) {
351
880
  return synthesizeSsmlSafe(this, ssml, options);
352
881
  }
882
+ async synthesizeChunksSafe(chunks, options = {}) {
883
+ return synthesizeSsmlChunksSafe(this, chunks, {
884
+ ...options,
885
+ outputFormat: options.outputFormat ?? __privateGet(this, _options).outputFormat,
886
+ signal: options.signal ?? __privateGet(this, _options).signal,
887
+ timeoutMs: options.timeoutMs ?? __privateGet(this, _options).timeoutMs,
888
+ onProgress: options.onProgress ?? __privateGet(this, _options).onProgress
889
+ });
890
+ }
891
+ async synthesizeSsmlChunksSafe(chunks, options = {}) {
892
+ return this.synthesizeChunksSafe(chunks, options);
893
+ }
353
894
  };
354
895
  _options = new WeakMap();
355
896
 
@@ -405,6 +946,9 @@ async function fetchAzureVoiceCatalog(options) {
405
946
  const secondaryLocales = stringList(record.SecondaryLocaleList);
406
947
  const styles = stringList(record.StyleList);
407
948
  const status = normalizeStatus(record.Status);
949
+ const supportedTags = stringList(record.SupportedTags);
950
+ const unsupportedTags = stringList(record.UnsupportedTags);
951
+ const models = stringList(record.Models);
408
952
  const merged = {
409
953
  name: existing?.name ?? name,
410
954
  locale: existing?.locale ?? locale,
@@ -414,6 +958,12 @@ async function fetchAzureVoiceCatalog(options) {
414
958
  if (mergedSecondaryLocales.length > 0) merged.secondaryLocales = mergedSecondaryLocales;
415
959
  const mergedStyles = [.../* @__PURE__ */ new Set([...existing?.styles ?? [], ...styles])];
416
960
  if (mergedStyles.length > 0) merged.styles = mergedStyles;
961
+ const mergedSupportedTags = [.../* @__PURE__ */ new Set([...existing?.supportedTags ?? [], ...supportedTags])];
962
+ if (mergedSupportedTags.length > 0) merged.supportedTags = mergedSupportedTags;
963
+ const mergedUnsupportedTags = [.../* @__PURE__ */ new Set([...existing?.unsupportedTags ?? [], ...unsupportedTags])];
964
+ if (mergedUnsupportedTags.length > 0) merged.unsupportedTags = mergedUnsupportedTags;
965
+ const mergedModels = [.../* @__PURE__ */ new Set([...existing?.models ?? [], ...models])];
966
+ if (mergedModels.length > 0) merged.models = mergedModels;
417
967
  if (status) merged.status = status;
418
968
  else if (existing?.status) merged.status = existing.status;
419
969
  voices.set(key, merged);
@@ -434,11 +984,22 @@ export {
434
984
  AzureTtsClient,
435
985
  AzureTtsError,
436
986
  AzureTtsSdkError,
987
+ ChunkValidationError,
988
+ DEFAULT_OUTPUT_FORMAT,
989
+ MergeError,
990
+ SynthesisCancelledError,
991
+ SynthesisTimeoutError,
992
+ UnsupportedMergeFormatError,
993
+ canMergeAudioFormat,
437
994
  fetchAzureVoiceCatalog,
995
+ mergeAudioBuffers,
438
996
  mergeSynthesisResults,
997
+ resolveMergeAudioFormat,
998
+ resolveMimeType,
439
999
  synthesizeSpeech,
440
1000
  synthesizeSsml,
441
1001
  synthesizeSsmlChunks,
1002
+ synthesizeSsmlChunksSafe,
442
1003
  synthesizeSsmlSafe
443
1004
  };
444
1005
  //# sourceMappingURL=index.mjs.map