ssml-builder-js 2.12.0 → 2.13.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/core.d.ts CHANGED
@@ -188,13 +188,35 @@ interface BuildPartialSsmlOptions extends SsmlPartialContext {
188
188
  declare function buildPartialSsml(text: string, context?: SsmlPartialContext): string;
189
189
  declare function buildPartialSsml(options: BuildPartialSsmlOptions): string;
190
190
 
191
+ interface SsmlTextRange {
192
+ start: number;
193
+ end: number;
194
+ }
195
+ interface SsmlChunkContext {
196
+ voice?: string;
197
+ lang?: string;
198
+ prosody?: Record<string, string>;
199
+ }
200
+ interface SsmlChunk {
201
+ chunkIndex: number;
202
+ ssml: string;
203
+ originalTextRange: SsmlTextRange;
204
+ inheritedContext: SsmlChunkContext;
205
+ containedMarks: string[];
206
+ hasBackgroundAudio: boolean;
207
+ }
208
+ interface SplitSsmlOptions {
209
+ maxLength?: number;
210
+ /** Keeps `<mstts:backgroundaudio>` in every chunk instead of the first chunk only. */
211
+ replicateBackgroundAudio?: boolean;
212
+ }
191
213
  /**
192
214
  * Splits SSML into independently synthesizable documents without breaking XML
193
215
  * elements. Parent elements such as `<voice>` and `<prosody>` are copied into
194
216
  * every block. Paragraph and sentence elements therefore remain the natural
195
217
  * split points, while oversized text is split at word/character boundaries.
196
218
  */
197
- declare function splitSsmlDocument(ssml: string, maxLength?: number): string[];
219
+ declare function splitSsmlDocument(ssml: string, maxLength?: number | SplitSsmlOptions, options?: SplitSsmlOptions): SsmlChunk[];
198
220
 
199
221
  declare function parseSsml(xmlString: string): SsmlDocument;
200
222
 
@@ -292,6 +314,10 @@ interface AzureValidationOptions {
292
314
  allowHttpAudio?: boolean;
293
315
  customVoiceStyleMap?: Record<string, readonly string[]>;
294
316
  customVoiceDefinitions?: readonly AzureVoiceDefinition[];
317
+ /** Host-side validation hook for URL-bearing SSML attributes. */
318
+ urlValidator?: AzureUrlValidator;
319
+ /** Alias for urlValidator retained for applications that use the longer name. */
320
+ customUrlValidator?: AzureUrlValidator;
295
321
  languageAliases?: Record<string, string | readonly string[]>;
296
322
  maxLength?: number;
297
323
  /** Maximum XML element nesting depth, counting `<speak>` as depth 1. */
@@ -311,6 +337,14 @@ interface AzureValidationOptions {
311
337
  voiceCatalog?: readonly AzureVoiceDefinition[];
312
338
  voiceDefinitions?: readonly AzureVoiceDefinition[];
313
339
  }
340
+ type AzureUrlValidationResult = boolean | {
341
+ valid: boolean;
342
+ reason?: string;
343
+ };
344
+ type AzureUrlValidator = (url: string, context: {
345
+ tag: string;
346
+ attribute: string;
347
+ }) => AzureUrlValidationResult | Promise<AzureUrlValidationResult>;
314
348
  type AzureLanguageNormalizationOptions = Pick<AzureValidationOptions, "languageAliases" | "normalizeLanguage">;
315
349
  /** @deprecated Use AzureValidationOptions instead. */
316
350
  type AzureSsmlValidationOptions = AzureValidationOptions;
@@ -320,7 +354,15 @@ declare function isValidAzureAudioDuration(value: string): boolean;
320
354
  declare function normalizeAzureLanguage(language: string, options?: AzureLanguageNormalizationOptions): string;
321
355
  /** Compares two Azure language tags after BCP 47 and alias normalization. */
322
356
  declare function areAzureLanguagesEquivalent(first: string, second: string, options?: AzureLanguageNormalizationOptions): boolean;
323
- declare function validateAzureSsml(ssml: string, options?: AzureValidationOptions): SsmlDiagnostic[];
357
+ /**
358
+ * Validates Azure SSML synchronously unless a URL validator is supplied. URL
359
+ * validation is asynchronous-capable so hosts can perform DNS/private-network checks.
360
+ */
361
+ declare function validateAzureSsml(ssml: string, options?: Omit<AzureValidationOptions, "urlValidator" | "customUrlValidator">): SsmlDiagnostic[];
362
+ declare function validateAzureSsml(ssml: string, options: AzureValidationOptions & {
363
+ urlValidator?: AzureUrlValidator;
364
+ customUrlValidator?: AzureUrlValidator;
365
+ }): SsmlDiagnostic[] | Promise<SsmlDiagnostic[]>;
324
366
 
325
367
  interface AzureVoiceCatalogMetadata {
326
368
  apiVersion: string;
@@ -334,4 +376,4 @@ declare function getAzureVoiceCatalogMetadata(): AzureVoiceCatalogMetadata;
334
376
  /** Alias for consumers that refer to the bundled catalog as the built-in catalog. */
335
377
  declare const getBuiltInVoiceCatalogMetadata: typeof getAzureVoiceCatalogMetadata;
336
378
 
337
- export { type AudioElement, type AzureDiagnosticCode, type AzureLanguageNormalizationOptions, type AzureSsmlValidationOptions, type AzureValidationOptions, type AzureVoiceCatalogMetadata, type AzureVoiceDefinition, type AzureVoiceMetadata, type BookmarkElement, type BreakElement, type BuildPartialSsmlOptions, type CustomElement, type EmphasisElement, type ExpressAsElement, type ExtractSsmlTranslatableTextOptions, type FromPlainTextToSsmlOptions, type LangElement, type LexiconElement, type MapSsmlTextNodesOptions, type MarkElement, type MsttsAudioDurationElement, type MsttsEmbeddingElement, type MsttsSilenceElement, type MsttsTtsEmbeddingElement, type MsttsVisemeElement, type MsttsVoiceConversionElement, type NamedElement, type ParagraphElement, type PhonemeElement, type ProsodyElement, type SayAsElement, type SentenceElement, type SsmlAttributeValue, type SsmlAttributes, type SsmlBackgroundAudioNode, type SsmlBreakElement, type SsmlDiagnostic, type SsmlDiagnosticSeverity, type SsmlDiagnosticSource, type SsmlDialogNode, type SsmlDocument, type SsmlElement, type SsmlElementBase, type SsmlEmbeddingNode, type SsmlExpressAsElement, type SsmlNode, type SsmlPartialContext, type SsmlPartialProsody, type SsmlPartialVoice, type SsmlPhonemeElement, type SsmlProsodyElement, type SsmlSayAsElement, type SsmlStructureIntegrityResult, type SsmlStructureMismatch, type SsmlText, type SsmlTextNodeContext, type SsmlTtsEmbeddingNode, type SsmlTurnNode, type SsmlValidationError, type SsmlVoiceConversionNode, type SsmlVoiceElement, type SubElement, type VoiceElement, type WordElement, areAzureLanguagesEquivalent, buildPartialSsml, buildSsml, extractSsmlText, extractSsmlTranslatableText, fromPlainTextToSsml, getAzureVoiceCatalogMetadata, getBuiltInVoiceCatalogMetadata, isValidAzureAudioDuration, mapSsmlTextNodes, normalizeAzureLanguage, parseSsml, splitSsmlDocument, validateAzureSsml, validateSsml, validateSsmlStructureIntegrity };
379
+ export { type AudioElement, type AzureDiagnosticCode, type AzureLanguageNormalizationOptions, type AzureSsmlValidationOptions, type AzureUrlValidationResult, type AzureUrlValidator, type AzureValidationOptions, type AzureVoiceCatalogMetadata, type AzureVoiceDefinition, type AzureVoiceMetadata, type BookmarkElement, type BreakElement, type BuildPartialSsmlOptions, type CustomElement, type EmphasisElement, type ExpressAsElement, type ExtractSsmlTranslatableTextOptions, type FromPlainTextToSsmlOptions, type LangElement, type LexiconElement, type MapSsmlTextNodesOptions, type MarkElement, type MsttsAudioDurationElement, type MsttsEmbeddingElement, type MsttsSilenceElement, type MsttsTtsEmbeddingElement, type MsttsVisemeElement, type MsttsVoiceConversionElement, type NamedElement, type ParagraphElement, type PhonemeElement, type ProsodyElement, type SayAsElement, type SentenceElement, type SplitSsmlOptions, type SsmlAttributeValue, type SsmlAttributes, type SsmlBackgroundAudioNode, type SsmlBreakElement, type SsmlChunk, type SsmlChunkContext, type SsmlDiagnostic, type SsmlDiagnosticSeverity, type SsmlDiagnosticSource, type SsmlDialogNode, type SsmlDocument, type SsmlElement, type SsmlElementBase, type SsmlEmbeddingNode, type SsmlExpressAsElement, type SsmlNode, type SsmlPartialContext, type SsmlPartialProsody, type SsmlPartialVoice, type SsmlPhonemeElement, type SsmlProsodyElement, type SsmlSayAsElement, type SsmlStructureIntegrityResult, type SsmlStructureMismatch, type SsmlText, type SsmlTextNodeContext, type SsmlTextRange, type SsmlTtsEmbeddingNode, type SsmlTurnNode, type SsmlValidationError, type SsmlVoiceConversionNode, type SsmlVoiceElement, type SubElement, type VoiceElement, type WordElement, areAzureLanguagesEquivalent, buildPartialSsml, buildSsml, extractSsmlText, extractSsmlTranslatableText, fromPlainTextToSsml, getAzureVoiceCatalogMetadata, getBuiltInVoiceCatalogMetadata, isValidAzureAudioDuration, mapSsmlTextNodes, normalizeAzureLanguage, parseSsml, splitSsmlDocument, validateAzureSsml, validateSsml, validateSsmlStructureIntegrity };
package/dist/core.js CHANGED
@@ -1045,30 +1045,109 @@ function splitNode(document, node, maxLength, context = []) {
1045
1045
  flush();
1046
1046
  return parts;
1047
1047
  }
1048
- function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH) {
1049
- if (!Number.isInteger(maxLength) || maxLength <= 0) {
1048
+ function textFromNode(node) {
1049
+ if (typeof node === "string") return node;
1050
+ if (node.type === "text") return node.value;
1051
+ return (node.children ?? []).map(textFromNode).join("");
1052
+ }
1053
+ function collectMarks(node, marks) {
1054
+ if (typeof node === "string" || node.type === "text") return;
1055
+ if (node.type === "mark" && node.name) marks.push(node.name);
1056
+ if (node.type === "bookmark" && node.mark) marks.push(node.mark);
1057
+ for (const child of node.children ?? []) collectMarks(child, marks);
1058
+ }
1059
+ function collectInheritedContext(nodes) {
1060
+ const context = {};
1061
+ const visit = (node) => {
1062
+ if (typeof node === "string" || node.type === "text") return;
1063
+ if (context.voice === void 0 && node.type === "voice" && node.name) context.voice = node.name;
1064
+ if (context.lang === void 0 && node.type === "lang" && node.lang) context.lang = node.lang;
1065
+ if (context.prosody === void 0 && node.type === "prosody") {
1066
+ const prosody = {};
1067
+ for (const [key, value] of Object.entries(node.attributes ?? {})) prosody[key] = String(value);
1068
+ for (const key of ["rate", "pitch", "volume", "contour", "range"]) {
1069
+ const value = node[key];
1070
+ if (value !== void 0) prosody[key] = String(value);
1071
+ }
1072
+ if (Object.keys(prosody).length > 0) context.prosody = prosody;
1073
+ }
1074
+ for (const child of node.children ?? []) visit(child);
1075
+ };
1076
+ nodes.forEach(visit);
1077
+ return context;
1078
+ }
1079
+ function createChunk(document, nodes, chunkIndex, textStart, backgroundAudio, replicateBackgroundAudio) {
1080
+ const chunkNodes = backgroundAudio && (replicateBackgroundAudio || chunkIndex === 0) ? [backgroundAudio, ...nodes] : nodes;
1081
+ const text = nodes.map(textFromNode).join("");
1082
+ const marks = [];
1083
+ for (const node of nodes) collectMarks(node, marks);
1084
+ const inheritedContext = collectInheritedContext(nodes);
1085
+ if (inheritedContext.lang === void 0 && document.lang) inheritedContext.lang = document.lang;
1086
+ return {
1087
+ chunkIndex,
1088
+ ssml: documentWithChildren(document, chunkNodes),
1089
+ originalTextRange: { start: textStart, end: textStart + text.length },
1090
+ inheritedContext,
1091
+ containedMarks: marks,
1092
+ hasBackgroundAudio: chunkNodes.some(
1093
+ (node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
1094
+ )
1095
+ };
1096
+ }
1097
+ function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH, options = {}) {
1098
+ const resolvedMaxLength = typeof maxLength === "number" ? maxLength : maxLength.maxLength ?? DEFAULT_MAX_LENGTH;
1099
+ const resolvedOptions = typeof maxLength === "number" ? options : maxLength;
1100
+ if (!Number.isInteger(resolvedMaxLength) || resolvedMaxLength <= 0) {
1050
1101
  throw new RangeError("maxLength must be a positive integer");
1051
1102
  }
1052
1103
  const document = parseSsml(ssml);
1053
- if (ssml.length <= maxLength) return [ssml];
1054
- const children = document.children ?? [];
1055
- const splitChildren = children.flatMap((child) => splitNode(document, child, maxLength));
1104
+ const backgroundAudio = (document.children ?? []).find(
1105
+ (node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
1106
+ );
1107
+ if (ssml.length <= resolvedMaxLength) {
1108
+ return [createChunk(document, document.children ?? [], 0, 0, backgroundAudio, true)];
1109
+ }
1110
+ const contentChildren = (document.children ?? []).filter((node) => node !== backgroundAudio);
1111
+ const plainDocumentLength = documentWithChildren(document, []).length;
1112
+ const backgroundDocumentLength = backgroundAudio ? documentWithChildren(document, [backgroundAudio]).length : plainDocumentLength;
1113
+ const backgroundOverhead = Math.max(0, backgroundDocumentLength - plainDocumentLength);
1114
+ const contentMaxLength = Math.max(1, resolvedMaxLength - backgroundOverhead);
1115
+ const splitChildren = contentChildren.flatMap((child) => splitNode(document, child, contentMaxLength));
1056
1116
  const chunks = [];
1057
1117
  let group = [];
1058
1118
  for (const child of splitChildren) {
1059
1119
  const candidate = [...group, child];
1060
- if (documentWithChildren(document, candidate).length <= maxLength) {
1120
+ if (documentWithChildren(document, candidate).length <= contentMaxLength) {
1061
1121
  group = candidate;
1062
1122
  continue;
1063
1123
  }
1064
1124
  if (group.length > 0) chunks.push(group);
1065
1125
  group = [child];
1066
- if (documentWithChildren(document, group).length > maxLength) {
1126
+ if (documentWithChildren(document, group).length > contentMaxLength) {
1067
1127
  throw new RangeError("maxLength is too small to contain the SSML document wrapper");
1068
1128
  }
1069
1129
  }
1070
1130
  if (group.length > 0) chunks.push(group);
1071
- return chunks.map((chunk) => documentWithChildren(document, chunk));
1131
+ if (chunks.length === 0) {
1132
+ const result = createChunk(document, [], 0, 0, backgroundAudio, resolvedOptions.replicateBackgroundAudio ?? false);
1133
+ if (result.ssml.length > resolvedMaxLength) {
1134
+ throw new RangeError("maxLength is too small to contain the SSML document wrapper");
1135
+ }
1136
+ return [result];
1137
+ }
1138
+ let textStart = 0;
1139
+ return chunks.map((chunk, chunkIndex) => {
1140
+ const result = createChunk(
1141
+ document,
1142
+ chunk,
1143
+ chunkIndex,
1144
+ textStart,
1145
+ backgroundAudio,
1146
+ resolvedOptions.replicateBackgroundAudio ?? false
1147
+ );
1148
+ textStart = result.originalTextRange.end;
1149
+ return result;
1150
+ });
1072
1151
  }
1073
1152
 
1074
1153
  // packages/ssml-core/src/validation.ts
@@ -2159,7 +2238,7 @@ function validateElement(token, source, diagnostics, voiceName, options, voiceCa
2159
2238
  addDiagnostic(diagnostics, source, token.start, "<mstts:backgroundaudio> must be self-closing.");
2160
2239
  }
2161
2240
  }
2162
- function validateAzureSsml(ssml, options = {}) {
2241
+ function validateAzureSsmlStatic(ssml, options = {}) {
2163
2242
  const diagnostics = [];
2164
2243
  if (typeof ssml !== "string") {
2165
2244
  return [
@@ -2283,6 +2362,51 @@ function validateAzureSsml(ssml, options = {}) {
2283
2362
  }
2284
2363
  return diagnostics;
2285
2364
  }
2365
+ function urlAttributes(token) {
2366
+ const tag = canonicalTagName(token.name);
2367
+ const attributes = tag === "audio" || tag === "mstts:backgroundaudio" ? ["src"] : tag === "lexicon" ? ["uri"] : tag === "mstts:voiceconversion" ? ["url"] : [];
2368
+ return attributes.flatMap((attribute) => {
2369
+ const value = attr(token, attribute);
2370
+ return value === void 0 ? [] : [{ attribute, value }];
2371
+ });
2372
+ }
2373
+ function validateAzureSsml(ssml, options = {}) {
2374
+ const diagnostics = validateAzureSsmlStatic(ssml, options);
2375
+ const validator = options.urlValidator ?? options.customUrlValidator;
2376
+ if (!validator || typeof ssml !== "string") return diagnostics;
2377
+ let tokens;
2378
+ try {
2379
+ tokens = tokenizeElements(ssml);
2380
+ } catch {
2381
+ return diagnostics;
2382
+ }
2383
+ const checks = tokens.flatMap(
2384
+ (token) => urlAttributes(token).map(async ({ attribute, value }) => {
2385
+ try {
2386
+ const result = await validator(value, { tag: token.name, attribute });
2387
+ const valid = typeof result === "boolean" ? result : result.valid;
2388
+ if (!valid) {
2389
+ const reason = typeof result === "boolean" ? void 0 : result.reason;
2390
+ addDiagnostic(
2391
+ diagnostics,
2392
+ ssml,
2393
+ token.start,
2394
+ `<${token.name} ${attribute}> was rejected by the custom URL validator${reason ? `: ${reason}` : "."}`
2395
+ );
2396
+ }
2397
+ } catch (error) {
2398
+ const reason = error instanceof Error ? error.message : String(error);
2399
+ addDiagnostic(
2400
+ diagnostics,
2401
+ ssml,
2402
+ token.start,
2403
+ `<${token.name} ${attribute}> could not be validated by the custom URL validator: ${reason}`
2404
+ );
2405
+ }
2406
+ })
2407
+ );
2408
+ return Promise.all(checks).then(() => diagnostics);
2409
+ }
2286
2410
 
2287
2411
  // packages/ssml-core/src/generated/azureVoiceCatalog.ts
2288
2412
  var AZURE_VOICE_CATALOG_METADATA = {