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