ssml-builder-js 2.4.0 → 2.5.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-ALFYHSCR.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,23 @@ 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 validateElement(token, source, diagnostics, voiceName, options, voiceStyleMap) {
1211
1267
  const name = token.name.toLowerCase();
1212
1268
  if (name === "voice" && !attr(token, "name")?.trim())
1213
1269
  addDiagnostic(diagnostics, source, token.start, '<voice> requires a non-empty "name" attribute.');
@@ -1249,9 +1305,24 @@ function validateElement(token, source, diagnostics, voiceName, options) {
1249
1305
  const role = attr(token, "role");
1250
1306
  if (role && !ALLOWED_ROLES.has(role))
1251
1307
  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}".`);
1308
+ const supportedStyles = voiceName ? voiceStyleMap.get(voiceName.toLowerCase()) : void 0;
1309
+ const severity = diagnosticSeverity(options.unknownVoicePolicy ?? "warn");
1310
+ if (style && supportedStyles && !supportedStyles.includes(style.toLowerCase()) && severity)
1311
+ addDiagnostic(
1312
+ diagnostics,
1313
+ source,
1314
+ token.start,
1315
+ `Unknown style "${style}" is not supported by voice "${voiceName}" according to the configured voice style map.`,
1316
+ severity
1317
+ );
1318
+ if (style && voiceName && !supportedStyles && severity)
1319
+ addDiagnostic(
1320
+ diagnostics,
1321
+ source,
1322
+ token.start,
1323
+ `Unknown style "${style}" cannot be verified because voice "${voiceName}" is not registered in the voice style map.`,
1324
+ severity
1325
+ );
1255
1326
  }
1256
1327
  if (name === "say-as" || name === "sayas") {
1257
1328
  const interpretAs = attr(token, "interpret-as");
@@ -1311,6 +1382,14 @@ function validateElement(token, source, diagnostics, voiceName, options) {
1311
1382
  addDiagnostic(diagnostics, source, token.start, "<audio src> must use HTTPS.");
1312
1383
  if (options.allowedAudioOrigins && !options.allowedAudioOrigins.includes(parsed.origin))
1313
1384
  addDiagnostic(diagnostics, source, token.start, `<audio src> origin "${parsed.origin}" is not allowed.`);
1385
+ else if (!options.allowExternalAudio)
1386
+ addDiagnostic(
1387
+ diagnostics,
1388
+ source,
1389
+ token.start,
1390
+ `<audio src> external origin "${parsed.origin}" is blocked by default; set allowExternalAudio to true or provide allowedAudioOrigins.`,
1391
+ "error"
1392
+ );
1314
1393
  }
1315
1394
  }
1316
1395
  }
@@ -1341,7 +1420,24 @@ function validateAzureSsml(ssml, options = {}) {
1341
1420
  "Azure SSML requires at least one <voice> element under <speak>."
1342
1421
  );
1343
1422
  const voiceName = voices[0] ? attr(voices[0], "name") : void 0;
1344
- for (const token of tokens) validateElement(token, ssml, diagnostics, voiceName, options);
1423
+ const voiceStyleMap = normalizeVoiceStyleMap(options.customVoiceStyleMap);
1424
+ const policySeverity = diagnosticSeverity(options.unknownVoicePolicy ?? "warn");
1425
+ const voicesToValidate = options.validateNestedVoices === false ? voices.slice(0, 1) : voices;
1426
+ for (const token of voicesToValidate) {
1427
+ const name = attr(token, "name")?.trim();
1428
+ if (name && !voiceStyleMap.has(name.toLowerCase()) && policySeverity)
1429
+ addDiagnostic(
1430
+ diagnostics,
1431
+ ssml,
1432
+ token.start,
1433
+ `Unknown voice "${name}" is not registered in the voice style map.`,
1434
+ policySeverity
1435
+ );
1436
+ }
1437
+ for (const token of tokens) {
1438
+ const tokenVoiceName = options.validateNestedVoices === false ? voiceName : token.parentVoiceName;
1439
+ validateElement(token, ssml, diagnostics, tokenVoiceName, options, voiceStyleMap);
1440
+ }
1345
1441
  return diagnostics;
1346
1442
  }
1347
1443
 
@@ -1452,30 +1548,56 @@ function closeSpeechResources(speechConfig, synthesizer) {
1452
1548
  }
1453
1549
  }
1454
1550
  async function synthesizeSpeech(ssml, config) {
1551
+ if (config.signal?.aborted) {
1552
+ throw createSpeechSdkError("Speech synthesis was cancelled.");
1553
+ }
1455
1554
  const speechConfig = createSpeechConfig(config);
1456
1555
  const synthesizer = new SpeechSDK2.SpeechSynthesizer(speechConfig, null);
1457
1556
  return await new Promise((resolve, reject) => {
1458
1557
  let resourcesClosed = false;
1558
+ let settled = false;
1559
+ let timeout;
1560
+ let abortHandler;
1561
+ const cleanup = () => {
1562
+ if (timeout) clearTimeout(timeout);
1563
+ if (abortHandler) config.signal?.removeEventListener("abort", abortHandler);
1564
+ };
1459
1565
  const closeResources = () => {
1460
1566
  if (resourcesClosed) return;
1461
1567
  resourcesClosed = true;
1462
1568
  closeSpeechResources(speechConfig, synthesizer);
1463
1569
  };
1464
1570
  const rejectWithError = (error) => {
1571
+ if (settled) return;
1572
+ settled = true;
1573
+ cleanup();
1465
1574
  closeResources();
1466
1575
  reject(createSpeechSdkError(error));
1467
1576
  };
1468
1577
  const cb = (result) => {
1578
+ if (settled) return;
1469
1579
  const { reason, errorDetails } = result;
1470
1580
  if (reason !== SpeechSDK2.ResultReason.SynthesizingAudioCompleted) {
1471
1581
  const err = errorDetails || `Speech synthesis failed with reason ${reason}.`;
1472
1582
  rejectWithError(err);
1473
1583
  return;
1474
1584
  }
1585
+ settled = true;
1586
+ cleanup();
1475
1587
  closeResources();
1476
1588
  resolve(result.audioData);
1477
1589
  };
1478
1590
  try {
1591
+ if (config.signal) {
1592
+ abortHandler = () => rejectWithError("Speech synthesis was cancelled.");
1593
+ config.signal.addEventListener("abort", abortHandler, { once: true });
1594
+ }
1595
+ if (config.timeoutMs !== void 0 && config.timeoutMs > 0) {
1596
+ timeout = setTimeout(
1597
+ () => rejectWithError(`Speech synthesis timed out after ${config.timeoutMs} ms.`),
1598
+ config.timeoutMs
1599
+ );
1600
+ }
1479
1601
  synthesizer.speakSsmlAsync(ssml, cb, rejectWithError);
1480
1602
  } catch (error) {
1481
1603
  rejectWithError(error);
@@ -1492,10 +1614,10 @@ var AzureTtsClient = class {
1492
1614
  __privateSet(this, _options, options);
1493
1615
  }
1494
1616
  async synthesize(ssml) {
1495
- const { region, subscriptionKey, outputFormat } = __privateGet(this, _options);
1617
+ const { region, subscriptionKey, outputFormat, signal, timeoutMs } = __privateGet(this, _options);
1496
1618
  const endpoint = __privateGet(this, _options).endpoint?.trim() || ENDPOINT_TEMPLATE.replace("{region}", region);
1497
1619
  __privateGet(this, _options).logger?.debug?.("Using Azure TTS endpoint:", endpoint);
1498
- const config = { endpoint, region, subscriptionKey, outputFormat };
1620
+ const config = { endpoint, region, subscriptionKey, outputFormat, signal, timeoutMs };
1499
1621
  return synthesizeSpeech(ssml, config);
1500
1622
  }
1501
1623
  };