ssml-builder-js 2.4.0 → 2.6.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/elements.mjs CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  registerSsmlCompletionProvider,
34
34
  resolveExpressAsStyles,
35
35
  updateEditableText
36
- } from "./chunk-I3BR2WCQ.mjs";
36
+ } from "./chunk-GF4QZHLJ.mjs";
37
37
  import "./chunk-6S5ODO6A.mjs";
38
38
 
39
39
  // packages/ssml-editor-react/src/ssmlInsertions.ts
package/dist/index.d.mts CHANGED
@@ -1,6 +1,8 @@
1
- export { AudioElement, AzureSsmlValidationOptions, BookmarkElement, BreakElement, BuildPartialSsmlOptions, CustomElement, EmphasisElement, ExpressAsElement, LangElement, LexiconElement, MarkElement, MsttsSilenceElement, MsttsVisemeElement, NamedElement, ParagraphElement, PhonemeElement, ProsodyElement, SayAsElement, SentenceElement, SsmlAttributeValue, SsmlAttributes, SsmlBreakElement, SsmlDiagnostic, SsmlDiagnosticSeverity, SsmlDocument, SsmlElement, SsmlElementBase, SsmlExpressAsElement, SsmlNode, SsmlPartialContext, SsmlPartialProsody, SsmlPartialVoice, SsmlPhonemeElement, SsmlProsodyElement, SsmlSayAsElement, SsmlText, SsmlTextNodeContext, SsmlValidationError, SsmlVoiceElement, SubElement, VoiceElement, WordElement, buildPartialSsml, buildSsml, extractSsmlText, mapSsmlTextNodes, parseSsml, validateAzureSsml, validateSsml } from './core.mjs';
1
+ export { AudioElement, AzureSsmlValidationOptions, AzureValidationOptions, BookmarkElement, BreakElement, BuildPartialSsmlOptions, CustomElement, EmphasisElement, ExpressAsElement, LangElement, LexiconElement, MapSsmlTextNodesOptions, MarkElement, MsttsSilenceElement, MsttsVisemeElement, NamedElement, ParagraphElement, PhonemeElement, ProsodyElement, SayAsElement, SentenceElement, SsmlAttributeValue, SsmlAttributes, SsmlBreakElement, SsmlDiagnostic, SsmlDiagnosticSeverity, SsmlDocument, SsmlElement, SsmlElementBase, SsmlExpressAsElement, SsmlNode, SsmlPartialContext, SsmlPartialProsody, SsmlPartialVoice, SsmlPhonemeElement, SsmlProsodyElement, SsmlSayAsElement, SsmlText, SsmlTextNodeContext, SsmlValidationError, SsmlVoiceElement, SubElement, VoiceElement, WordElement, buildPartialSsml, buildSsml, extractSsmlText, mapSsmlTextNodes, parseSsml, validateAzureSsml, validateSsml } from './core.mjs';
2
2
 
3
3
  interface TtsConfig {
4
+ signal?: AbortSignal;
5
+ timeoutMs?: number;
4
6
  endpoint?: string;
5
7
  subscriptionKey: string;
6
8
  region: string;
@@ -13,6 +15,8 @@ interface AzureTtsLogger {
13
15
  error?: (...args: unknown[]) => void;
14
16
  }
15
17
  interface AzureTtsClientOptions {
18
+ signal?: AbortSignal;
19
+ timeoutMs?: number;
16
20
  subscriptionKey: string;
17
21
  region: string;
18
22
  endpoint?: string;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
- export { AudioElement, AzureSsmlValidationOptions, BookmarkElement, BreakElement, BuildPartialSsmlOptions, CustomElement, EmphasisElement, ExpressAsElement, LangElement, LexiconElement, MarkElement, MsttsSilenceElement, MsttsVisemeElement, NamedElement, ParagraphElement, PhonemeElement, ProsodyElement, SayAsElement, SentenceElement, SsmlAttributeValue, SsmlAttributes, SsmlBreakElement, SsmlDiagnostic, SsmlDiagnosticSeverity, SsmlDocument, SsmlElement, SsmlElementBase, SsmlExpressAsElement, SsmlNode, SsmlPartialContext, SsmlPartialProsody, SsmlPartialVoice, SsmlPhonemeElement, SsmlProsodyElement, SsmlSayAsElement, SsmlText, SsmlTextNodeContext, SsmlValidationError, SsmlVoiceElement, SubElement, VoiceElement, WordElement, buildPartialSsml, buildSsml, extractSsmlText, mapSsmlTextNodes, parseSsml, validateAzureSsml, validateSsml } from './core.js';
1
+ export { AudioElement, AzureSsmlValidationOptions, AzureValidationOptions, BookmarkElement, BreakElement, BuildPartialSsmlOptions, CustomElement, EmphasisElement, ExpressAsElement, LangElement, LexiconElement, MapSsmlTextNodesOptions, MarkElement, MsttsSilenceElement, MsttsVisemeElement, NamedElement, ParagraphElement, PhonemeElement, ProsodyElement, SayAsElement, SentenceElement, SsmlAttributeValue, SsmlAttributes, SsmlBreakElement, SsmlDiagnostic, SsmlDiagnosticSeverity, SsmlDocument, SsmlElement, SsmlElementBase, SsmlExpressAsElement, SsmlNode, SsmlPartialContext, SsmlPartialProsody, SsmlPartialVoice, SsmlPhonemeElement, SsmlProsodyElement, SsmlSayAsElement, SsmlText, SsmlTextNodeContext, SsmlValidationError, SsmlVoiceElement, SubElement, VoiceElement, WordElement, buildPartialSsml, buildSsml, extractSsmlText, mapSsmlTextNodes, parseSsml, validateAzureSsml, validateSsml } from './core.js';
2
2
 
3
3
  interface TtsConfig {
4
+ signal?: AbortSignal;
5
+ timeoutMs?: number;
4
6
  endpoint?: string;
5
7
  subscriptionKey: string;
6
8
  region: string;
@@ -13,6 +15,8 @@ interface AzureTtsLogger {
13
15
  error?: (...args: unknown[]) => void;
14
16
  }
15
17
  interface AzureTtsClientOptions {
18
+ signal?: AbortSignal;
19
+ timeoutMs?: number;
16
20
  subscriptionKey: string;
17
21
  region: string;
18
22
  endpoint?: string;
package/dist/index.js CHANGED
@@ -911,6 +911,9 @@ function decodeXmlText(value) {
911
911
  function encodeXmlText(value) {
912
912
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
913
913
  }
914
+ function decodeXmlAttribute(value) {
915
+ return decodeXmlText(value);
916
+ }
914
917
  function findTagEnd(source, start) {
915
918
  let quote = "";
916
919
  for (let index = start; index < source.length; index += 1) {
@@ -929,14 +932,31 @@ function readTagName(tag) {
929
932
  const match = /^<\s*([A-Za-z_][A-Za-z0-9_.:-]*)/.exec(tag);
930
933
  return match?.[1];
931
934
  }
935
+ function readTagAttributes(tag, name) {
936
+ const attributes = {};
937
+ const nameStart = tag.indexOf(name);
938
+ const attributeSource = tag.slice(nameStart + name.length, tag.length - 1).replace(/\/\s*$/, "");
939
+ const attributePattern = /([A-Za-z_][A-Za-z0-9_.:-]*)\s*=\s*(["'])([\s\S]*?)\2/g;
940
+ for (const match of attributeSource.matchAll(attributePattern)) {
941
+ attributes[match[1].toLowerCase()] = decodeXmlAttribute(match[3]);
942
+ }
943
+ return attributes;
944
+ }
932
945
  function collectTextNodes(source) {
933
946
  const nodes = [];
934
- const path = [];
947
+ const elements = [];
935
948
  let index = 0;
936
949
  const addText = (start, end, rawText, sourceStart = start, sourceEnd = end) => {
937
950
  if (!rawText) return;
951
+ const path = elements.map((element) => element.name);
952
+ const parent = elements[elements.length - 1];
938
953
  nodes.push({
939
- context: { parentTag: path[path.length - 1] ?? "", path: [...path] },
954
+ context: {
955
+ ancestorTags: path.slice(0, -1),
956
+ parentAttributes: { ...parent?.attributes ?? {} },
957
+ parentTag: parent?.name ?? "",
958
+ path
959
+ },
940
960
  decodedText: decodeXmlText(rawText),
941
961
  end,
942
962
  sourceEnd,
@@ -978,14 +998,14 @@ function collectTextNodes(source) {
978
998
  }
979
999
  if (source.startsWith("</", index)) {
980
1000
  const end2 = findTagEnd(source, index + 2);
981
- path.pop();
1001
+ elements.pop();
982
1002
  index = end2 + 1;
983
1003
  continue;
984
1004
  }
985
1005
  const end = findTagEnd(source, index + 1);
986
1006
  const tag = source.slice(index, end + 1);
987
1007
  const name = readTagName(tag);
988
- if (name && !/\/\s*>$/.test(tag)) path.push(name);
1008
+ if (name && !/\/\s*>$/.test(tag)) elements.push({ attributes: readTagAttributes(tag, name), name });
989
1009
  index = end + 1;
990
1010
  }
991
1011
  return nodes;
@@ -994,15 +1014,21 @@ function extractSsmlText(ssml) {
994
1014
  parseSsml(ssml);
995
1015
  return collectTextNodes(ssml).map((node) => node.decodedText);
996
1016
  }
997
- async function mapSsmlTextNodes(ssml, transform) {
1017
+ async function mapSsmlTextNodes(ssml, transform, options = {}) {
998
1018
  parseSsml(ssml);
999
1019
  const nodes = collectTextNodes(ssml);
1020
+ const skipTags = new Set((options.skipTags ?? ["phoneme", "say-as", "sub"]).map((tag) => tag.toLowerCase()));
1000
1021
  const replacements = await Promise.all(
1001
1022
  nodes.map(async (node) => {
1002
- const transformed = await transform(node.decodedText, {
1023
+ const context = {
1024
+ ancestorTags: [...node.context.ancestorTags],
1025
+ parentAttributes: { ...node.context.parentAttributes },
1003
1026
  parentTag: node.context.parentTag,
1004
1027
  path: [...node.context.path]
1005
- });
1028
+ };
1029
+ const shouldTransform = !skipTags.has(context.parentTag.toLowerCase()) && (options.filter?.(context) ?? true);
1030
+ if (!shouldTransform) return ssml.slice(node.sourceStart, node.sourceEnd);
1031
+ const transformed = await transform(node.decodedText, context);
1006
1032
  if (typeof transformed !== "string") {
1007
1033
  throw new TypeError("SSML text node transform must return a string");
1008
1034
  }
@@ -1159,6 +1185,7 @@ function findTagEnd2(source, start) {
1159
1185
  }
1160
1186
  function tokenizeElements(source) {
1161
1187
  const tokens = [];
1188
+ const openElements = [];
1162
1189
  let index = 0;
1163
1190
  while (index < source.length) {
1164
1191
  const start = source.indexOf("<", index);
@@ -1180,8 +1207,13 @@ function tokenizeElements(source) {
1180
1207
  }
1181
1208
  const end = findTagEnd2(source, start + 1);
1182
1209
  const raw = source.slice(start, end + 1);
1210
+ if (raw.startsWith("</")) {
1211
+ openElements.pop();
1212
+ index = end + 1;
1213
+ continue;
1214
+ }
1183
1215
  const nameMatch = /^<\s*([A-Za-z_][A-Za-z0-9_.:-]*)/.exec(raw);
1184
- if (!nameMatch?.[1] || raw.startsWith("</")) {
1216
+ if (!nameMatch?.[1]) {
1185
1217
  index = end + 1;
1186
1218
  continue;
1187
1219
  }
@@ -1191,7 +1223,15 @@ function tokenizeElements(source) {
1191
1223
  for (const match of attributeSource.matchAll(attributePattern)) {
1192
1224
  attributes.set(match[1].toLowerCase(), decodeAttribute(match[3]));
1193
1225
  }
1194
- tokens.push({ attributes, end, name: nameMatch[1], selfClosing: /\/\s*>$/.test(raw), start });
1226
+ const selfClosing = /\/\s*>$/.test(raw);
1227
+ const parentVoiceName = [...openElements].reverse().find((element) => element.voiceName)?.voiceName;
1228
+ tokens.push({ attributes, end, name: nameMatch[1], parentVoiceName, selfClosing, start });
1229
+ if (!selfClosing) {
1230
+ openElements.push({
1231
+ name: nameMatch[1],
1232
+ voiceName: nameMatch[1].toLowerCase() === "voice" ? attributes.get("name") : parentVoiceName
1233
+ });
1234
+ }
1195
1235
  index = end + 1;
1196
1236
  }
1197
1237
  return tokens;
@@ -1207,7 +1247,45 @@ function addDiagnostic(diagnostics, source, offset, message, severity = "error")
1207
1247
  function attr(token, name) {
1208
1248
  return token.attributes.get(name.toLowerCase());
1209
1249
  }
1210
- function validateElement(token, source, diagnostics, voiceName, options) {
1250
+ function normalizeVoiceStyleMap(customVoiceStyleMap) {
1251
+ const map = new Map(
1252
+ Object.entries(EXPRESS_AS_STYLES).map(([voiceName, styles]) => [voiceName.toLowerCase(), styles])
1253
+ );
1254
+ for (const [voiceName, styles] of Object.entries(customVoiceStyleMap ?? {})) {
1255
+ map.set(
1256
+ voiceName.toLowerCase(),
1257
+ styles.map((style) => style.toLowerCase())
1258
+ );
1259
+ }
1260
+ return map;
1261
+ }
1262
+ function diagnosticSeverity(policy) {
1263
+ if (policy === "ignore") return void 0;
1264
+ return policy === "error" ? "error" : "warning";
1265
+ }
1266
+ function voiceLocalePrefix(voiceName) {
1267
+ const match = /^(?<language>[A-Za-z]{2,3})-(?<region>[A-Za-z]{2}|\d{3})(?:-|$)/.exec(voiceName.trim());
1268
+ if (!match?.groups) return void 0;
1269
+ return {
1270
+ language: match.groups.language.toLowerCase(),
1271
+ region: match.groups.region.toLowerCase()
1272
+ };
1273
+ }
1274
+ function languageLocalePrefix(language) {
1275
+ const match = /^(?<language>[A-Za-z]{2,3})(?:-(?<region>[A-Za-z]{2}|\d{3}))?(?:-|$)/.exec(language.trim());
1276
+ if (!match?.groups) return void 0;
1277
+ return {
1278
+ language: match.groups.language.toLowerCase(),
1279
+ region: match.groups.region?.toLowerCase()
1280
+ };
1281
+ }
1282
+ function voiceMatchesLanguage(voiceName, language) {
1283
+ const voiceLocale = voiceLocalePrefix(voiceName);
1284
+ const languageLocale = languageLocalePrefix(language);
1285
+ if (!voiceLocale || !languageLocale) return void 0;
1286
+ return voiceLocale.language === languageLocale.language && (languageLocale.region === void 0 || voiceLocale.region === languageLocale.region);
1287
+ }
1288
+ function validateElement(token, source, diagnostics, voiceName, options, voiceStyleMap) {
1211
1289
  const name = token.name.toLowerCase();
1212
1290
  if (name === "voice" && !attr(token, "name")?.trim())
1213
1291
  addDiagnostic(diagnostics, source, token.start, '<voice> requires a non-empty "name" attribute.');
@@ -1249,9 +1327,24 @@ function validateElement(token, source, diagnostics, voiceName, options) {
1249
1327
  const role = attr(token, "role");
1250
1328
  if (role && !ALLOWED_ROLES.has(role))
1251
1329
  addDiagnostic(diagnostics, source, token.start, `Unsupported <mstts:express-as role> value "${role}".`);
1252
- const supportedStyles = voiceName ? EXPRESS_AS_STYLES[voiceName.toLowerCase()] : void 0;
1253
- if (style && supportedStyles && !supportedStyles.includes(style.toLowerCase()))
1254
- addDiagnostic(diagnostics, source, token.start, `Style "${style}" is not supported by voice "${voiceName}".`);
1330
+ const supportedStyles = voiceName ? voiceStyleMap.get(voiceName.toLowerCase()) : void 0;
1331
+ const severity = diagnosticSeverity(options.unknownVoicePolicy ?? "warn");
1332
+ if (style && supportedStyles && !supportedStyles.includes(style.toLowerCase()) && severity)
1333
+ addDiagnostic(
1334
+ diagnostics,
1335
+ source,
1336
+ token.start,
1337
+ `Unknown style "${style}" is not supported by voice "${voiceName}" according to the configured voice style map.`,
1338
+ severity
1339
+ );
1340
+ if (style && voiceName && !supportedStyles && severity)
1341
+ addDiagnostic(
1342
+ diagnostics,
1343
+ source,
1344
+ token.start,
1345
+ `Unknown style "${style}" cannot be verified because voice "${voiceName}" is not registered in the voice style map.`,
1346
+ severity
1347
+ );
1255
1348
  }
1256
1349
  if (name === "say-as" || name === "sayas") {
1257
1350
  const interpretAs = attr(token, "interpret-as");
@@ -1311,6 +1404,14 @@ function validateElement(token, source, diagnostics, voiceName, options) {
1311
1404
  addDiagnostic(diagnostics, source, token.start, "<audio src> must use HTTPS.");
1312
1405
  if (options.allowedAudioOrigins && !options.allowedAudioOrigins.includes(parsed.origin))
1313
1406
  addDiagnostic(diagnostics, source, token.start, `<audio src> origin "${parsed.origin}" is not allowed.`);
1407
+ else if (!options.allowExternalAudio)
1408
+ addDiagnostic(
1409
+ diagnostics,
1410
+ source,
1411
+ token.start,
1412
+ `<audio src> external origin "${parsed.origin}" is blocked by default; set allowExternalAudio to true or provide allowedAudioOrigins.`,
1413
+ "error"
1414
+ );
1314
1415
  }
1315
1416
  }
1316
1417
  }
@@ -1341,7 +1442,33 @@ function validateAzureSsml(ssml, options = {}) {
1341
1442
  "Azure SSML requires at least one <voice> element under <speak>."
1342
1443
  );
1343
1444
  const voiceName = voices[0] ? attr(voices[0], "name") : void 0;
1344
- for (const token of tokens) validateElement(token, ssml, diagnostics, voiceName, options);
1445
+ const voiceStyleMap = normalizeVoiceStyleMap(options.customVoiceStyleMap);
1446
+ const policySeverity = diagnosticSeverity(options.unknownVoicePolicy ?? "warn");
1447
+ const voicesToValidate = options.validateNestedVoices === false ? voices.slice(0, 1) : voices;
1448
+ for (const token of voicesToValidate) {
1449
+ const name = attr(token, "name")?.trim();
1450
+ const language = attr(token, "xml:lang")?.trim() || (speak ? attr(speak, "xml:lang")?.trim() : void 0);
1451
+ if (name && !voiceStyleMap.has(name.toLowerCase()) && policySeverity)
1452
+ addDiagnostic(
1453
+ diagnostics,
1454
+ ssml,
1455
+ token.start,
1456
+ `Unknown voice "${name}" is not registered in the voice style map.`,
1457
+ policySeverity
1458
+ );
1459
+ if (name && language && voiceMatchesLanguage(name, language) === false)
1460
+ addDiagnostic(
1461
+ diagnostics,
1462
+ ssml,
1463
+ token.start,
1464
+ `Voice "${name}" does not match language "${language}"; the voice name prefix indicates a different language or region.`,
1465
+ "warning"
1466
+ );
1467
+ }
1468
+ for (const token of tokens) {
1469
+ const tokenVoiceName = options.validateNestedVoices === false ? voiceName : token.parentVoiceName;
1470
+ validateElement(token, ssml, diagnostics, tokenVoiceName, options, voiceStyleMap);
1471
+ }
1345
1472
  return diagnostics;
1346
1473
  }
1347
1474
 
@@ -1452,30 +1579,56 @@ function closeSpeechResources(speechConfig, synthesizer) {
1452
1579
  }
1453
1580
  }
1454
1581
  async function synthesizeSpeech(ssml, config) {
1582
+ if (config.signal?.aborted) {
1583
+ throw createSpeechSdkError("Speech synthesis was cancelled.");
1584
+ }
1455
1585
  const speechConfig = createSpeechConfig(config);
1456
1586
  const synthesizer = new SpeechSDK2.SpeechSynthesizer(speechConfig, null);
1457
1587
  return await new Promise((resolve, reject) => {
1458
1588
  let resourcesClosed = false;
1589
+ let settled = false;
1590
+ let timeout;
1591
+ let abortHandler;
1592
+ const cleanup = () => {
1593
+ if (timeout) clearTimeout(timeout);
1594
+ if (abortHandler) config.signal?.removeEventListener("abort", abortHandler);
1595
+ };
1459
1596
  const closeResources = () => {
1460
1597
  if (resourcesClosed) return;
1461
1598
  resourcesClosed = true;
1462
1599
  closeSpeechResources(speechConfig, synthesizer);
1463
1600
  };
1464
1601
  const rejectWithError = (error) => {
1602
+ if (settled) return;
1603
+ settled = true;
1604
+ cleanup();
1465
1605
  closeResources();
1466
1606
  reject(createSpeechSdkError(error));
1467
1607
  };
1468
1608
  const cb = (result) => {
1609
+ if (settled) return;
1469
1610
  const { reason, errorDetails } = result;
1470
1611
  if (reason !== SpeechSDK2.ResultReason.SynthesizingAudioCompleted) {
1471
1612
  const err = errorDetails || `Speech synthesis failed with reason ${reason}.`;
1472
1613
  rejectWithError(err);
1473
1614
  return;
1474
1615
  }
1616
+ settled = true;
1617
+ cleanup();
1475
1618
  closeResources();
1476
1619
  resolve(result.audioData);
1477
1620
  };
1478
1621
  try {
1622
+ if (config.signal) {
1623
+ abortHandler = () => rejectWithError("Speech synthesis was cancelled.");
1624
+ config.signal.addEventListener("abort", abortHandler, { once: true });
1625
+ }
1626
+ if (config.timeoutMs !== void 0 && config.timeoutMs > 0) {
1627
+ timeout = setTimeout(
1628
+ () => rejectWithError(`Speech synthesis timed out after ${config.timeoutMs} ms.`),
1629
+ config.timeoutMs
1630
+ );
1631
+ }
1479
1632
  synthesizer.speakSsmlAsync(ssml, cb, rejectWithError);
1480
1633
  } catch (error) {
1481
1634
  rejectWithError(error);
@@ -1492,10 +1645,10 @@ var AzureTtsClient = class {
1492
1645
  __privateSet(this, _options, options);
1493
1646
  }
1494
1647
  async synthesize(ssml) {
1495
- const { region, subscriptionKey, outputFormat } = __privateGet(this, _options);
1648
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1496
1649
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
1497
1650
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
1498
- const config = { endpoint, region, subscriptionKey, outputFormat };
1651
+ const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs };
1499
1652
  return synthesizeSpeech(ssml, config);
1500
1653
  }
1501
1654
  };