ssml-builder-js 2.13.0 → 2.14.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/README.md CHANGED
@@ -911,8 +911,16 @@ result.bookmarks; // { name, audioOffsetMs }[]
911
911
 
912
912
  長文を分割して合成する場合は `synthesizeSsmlChunks` または `AzureTtsClient.synthesizeChunks` を使います。音声バイナリを連結し、`boundaries`、`visemes`、`bookmarks` のオフセットを累積 `durationMs` 分だけ補正します。`synthesizeSsmlSafe(client, ssml, { validation })` は検証エラー時に Azure API を呼び出さず、`status: "validation-error"` / `"azure-api-error"` / `"success"` の結果を返します。
913
913
 
914
+ v2.14.0 では `synthesizeSsmlChunksSafe(client, chunks, options)` が全チャンクを事前検証し、エラー時に `ChunkValidationError.chunkIndex` を返します。`onProgress` には `chunkIndex`、`originalTextRange`、`status`、`durationMs`、`error` が含まれます。`mergeAudioBuffers(buffers, format)` は PCM WAV のヘッダーを再構築し、MP3 の ID3 タグを除去して結合します。Ogg/WebM など安全に連結できない形式は `UnsupportedMergeFormatError` になります。同期イベントには `chunkIndex`、`sourceNodePath`、`originalTextRange`、`chunkAudioOffsetMs` が付与されます。
915
+
916
+ `validateAzureSsml` の `urlValidation` オプションは URL の重複排除、キャッシュ、`concurrency`、`signal`、`timeoutMs` を制御します。
917
+
914
918
  For long documents, use `synthesizeSsmlChunks` or `AzureTtsClient.synthesizeChunks`; `onProgress` reports completed chunks while audio and synchronization offsets are merged. `synthesizeSsmlSafe(client, ssml, { validation })` validates before synthesis and returns a discriminated result without calling Azure when static validation fails.
915
919
 
920
+ In v2.14.0, `synthesizeSsmlChunksSafe(client, chunks, options)` validates every chunk before contacting Azure and returns a `ChunkValidationError` with its `chunkIndex` when validation fails. `onProgress` events include `chunkIndex`, `originalTextRange`, `status`, `durationMs`, and `error`. `mergeAudioBuffers(buffers, format)` rebuilds PCM WAV headers and strips ID3 tags from MP3 streams; formats such as Ogg and WebM throw `UnsupportedMergeFormatError` because they require re-multiplexing. Synchronization events retain `chunkIndex`, `sourceNodePath`, `originalTextRange`, and `chunkAudioOffsetMs`.
921
+
922
+ The `urlValidation` option of `validateAzureSsml` provides URL deduplication, in-memory caching, bounded `concurrency`, `signal`, and `timeoutMs` controls.
923
+
916
924
  The public updater CLI is `npx ssml-builder sync-voices --region eastus --output ./azure-voices.json`. It reads the key from `AZURE_SPEECH_KEY` (or `--key`) and regions from `AZURE_SPEECH_REGION(S)` (or `--region(s)`).
917
925
 
918
926
  ```ts
@@ -1034,6 +1034,34 @@ function collectInheritedContext(nodes) {
1034
1034
  nodes.forEach(visit);
1035
1035
  return context;
1036
1036
  }
1037
+ function elementName(node) {
1038
+ return node.type === "custom" || node.type === "element" ? node.name : node.type;
1039
+ }
1040
+ function findSourceNodePath(nodes, targetOffset) {
1041
+ let textOffset = 0;
1042
+ let firstPath;
1043
+ let foundPath;
1044
+ const visit = (node, path) => {
1045
+ if (typeof node === "string" || node.type === "text") {
1046
+ const text = typeof node === "string" ? node : node.value;
1047
+ if (text && firstPath === void 0) firstPath = [...path];
1048
+ if (text && foundPath === void 0 && targetOffset < textOffset + text.length) foundPath = [...path];
1049
+ textOffset += text.length;
1050
+ return;
1051
+ }
1052
+ node.children?.forEach((child, index) => {
1053
+ const childPath = typeof child === "string" || child.type === "text" ? path : [...path, `${elementName(child)}[${index}]`];
1054
+ visit(child, childPath);
1055
+ });
1056
+ };
1057
+ nodes.forEach((node, index) => {
1058
+ if (!foundPath) {
1059
+ if (typeof node === "string" || node.type === "text") visit(node, ["speak"]);
1060
+ else visit(node, ["speak", `${elementName(node)}[${index}]`]);
1061
+ }
1062
+ });
1063
+ return foundPath ?? firstPath;
1064
+ }
1037
1065
  function createChunk(document, nodes, chunkIndex, textStart, backgroundAudio, replicateBackgroundAudio) {
1038
1066
  const chunkNodes = backgroundAudio && (replicateBackgroundAudio || chunkIndex === 0) ? [backgroundAudio, ...nodes] : nodes;
1039
1067
  const text = nodes.map(textFromNode).join("");
@@ -1049,7 +1077,8 @@ function createChunk(document, nodes, chunkIndex, textStart, backgroundAudio, re
1049
1077
  containedMarks: marks,
1050
1078
  hasBackgroundAudio: chunkNodes.some(
1051
1079
  (node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
1052
- )
1080
+ ),
1081
+ sourceNodePath: findSourceNodePath(document.children ?? [], textStart)
1053
1082
  };
1054
1083
  }
1055
1084
  function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH, options = {}) {
@@ -1275,7 +1304,7 @@ async function mapSsmlTextNodes(ssml, transform, options = {}) {
1275
1304
 
1276
1305
  // packages/ssml-core/src/migration.ts
1277
1306
  var DEFAULT_TRANSLATION_SKIP_TAGS = ["phoneme", "say-as", "sayAs", "sub"];
1278
- function elementName(element) {
1307
+ function elementName2(element) {
1279
1308
  switch (element.type) {
1280
1309
  case "custom":
1281
1310
  case "element":
@@ -1428,7 +1457,7 @@ function extractSsmlTranslatableText(ssml, options = {}) {
1428
1457
  }
1429
1458
  return;
1430
1459
  }
1431
- const tag = elementName(node);
1460
+ const tag = elementName2(node);
1432
1461
  if (skipTags.has(tag.toLowerCase())) return;
1433
1462
  visit(childrenOf(node), [...ancestors, tag], [...path, String(index)]);
1434
1463
  });
@@ -1474,7 +1503,7 @@ function serializeDocument2(document) {
1474
1503
  const serialize = (node) => {
1475
1504
  if (typeof node === "string") return node.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1476
1505
  if (node.type === "text") return serialize(node.value);
1477
- const tag = elementName(node);
1506
+ const tag = elementName2(node);
1478
1507
  const nodeAttributes = elementAttributes(node);
1479
1508
  const serializedAttributes = Object.entries(nodeAttributes).map(([name, value]) => ` ${name}="${serialize(value).replace(/"/g, "&quot;")}"`).join("");
1480
1509
  const children = childrenOf(node).map(serialize).join("");
@@ -1488,7 +1517,7 @@ function flatten(document) {
1488
1517
  nodes.forEach((node, index) => {
1489
1518
  if (typeof node === "string" || node.type === "text") return;
1490
1519
  const currentPath = `${path}/${index}`;
1491
- result.push({ name: elementName(node), attributes: elementAttributes(node), path: currentPath });
1520
+ result.push({ name: elementName2(node), attributes: elementAttributes(node), path: currentPath });
1492
1521
  visit(childrenOf(node), currentPath);
1493
1522
  });
1494
1523
  };
@@ -1686,6 +1715,69 @@ var AZURE_VOICE_DEFINITIONS = [
1686
1715
  ];
1687
1716
 
1688
1717
  // packages/ssml-core/src/azureValidation.ts
1718
+ function createAzureUrlValidatorRunner(validator, options = {}) {
1719
+ if (typeof validator !== "function") throw new TypeError("A URL validator function is required.");
1720
+ const concurrency = options.concurrency === void 0 ? Infinity : Number.isFinite(options.concurrency) ? Math.max(1, Math.floor(options.concurrency)) : Infinity;
1721
+ const cache = options.cache ?? /* @__PURE__ */ new Map();
1722
+ const inFlight = /* @__PURE__ */ new Map();
1723
+ const waiters = [];
1724
+ let active = 0;
1725
+ const acquire = async () => {
1726
+ if (active < concurrency) {
1727
+ active += 1;
1728
+ return;
1729
+ }
1730
+ await new Promise((resolve) => waiters.push(resolve));
1731
+ active += 1;
1732
+ };
1733
+ const release = () => {
1734
+ active -= 1;
1735
+ waiters.shift()?.();
1736
+ };
1737
+ const check = async (url, context) => {
1738
+ if (options.signal?.aborted) throw new Error("URL validation was aborted.");
1739
+ const cached = cache.get(url);
1740
+ if (cached !== void 0) return cached;
1741
+ const existing = inFlight.get(url);
1742
+ if (existing) return existing;
1743
+ const promise = (async () => {
1744
+ await acquire();
1745
+ try {
1746
+ if (options.signal?.aborted) throw new Error("URL validation was aborted.");
1747
+ const validation = Promise.resolve(validator(url, context));
1748
+ let timer;
1749
+ let abortHandler;
1750
+ const cancellation = new Promise((_resolve, reject) => {
1751
+ abortHandler = () => reject(new Error("URL validation was aborted."));
1752
+ options.signal?.addEventListener("abort", abortHandler, { once: true });
1753
+ if (options.timeoutMs !== void 0 && options.timeoutMs > 0) {
1754
+ timer = setTimeout(
1755
+ () => reject(new Error(`URL validation timed out after ${options.timeoutMs} ms.`)),
1756
+ options.timeoutMs
1757
+ );
1758
+ }
1759
+ });
1760
+ try {
1761
+ const result = await (timer || abortHandler ? Promise.race([validation, cancellation]) : validation);
1762
+ cache.set(url, result);
1763
+ return result;
1764
+ } finally {
1765
+ if (timer) clearTimeout(timer);
1766
+ if (abortHandler) options.signal?.removeEventListener("abort", abortHandler);
1767
+ }
1768
+ } finally {
1769
+ release();
1770
+ }
1771
+ })();
1772
+ inFlight.set(url, promise);
1773
+ try {
1774
+ return await promise;
1775
+ } finally {
1776
+ inFlight.delete(url);
1777
+ }
1778
+ };
1779
+ return (url, context) => check(url, context);
1780
+ }
1689
1781
  var ALLOWED_BREAK_STRENGTHS = /* @__PURE__ */ new Set(["none", "x-weak", "weak", "medium", "strong", "x-strong"]);
1690
1782
  var ALLOWED_SAY_AS = /* @__PURE__ */ new Set([
1691
1783
  "characters",
@@ -1980,23 +2072,23 @@ function validateVoiceFeatureMatrix(token, source, diagnostics, voiceName, defin
1980
2072
  );
1981
2073
  }
1982
2074
  }
1983
- function validateAudioSource(token, source, diagnostics, options, elementName2) {
2075
+ function validateAudioSource(token, source, diagnostics, options, elementName3) {
1984
2076
  const src = attr(token, "src");
1985
2077
  if (!src) {
1986
- addDiagnostic(diagnostics, source, token.start, `<${elementName2}> requires a "src" attribute.`);
2078
+ addDiagnostic(diagnostics, source, token.start, `<${elementName3}> requires a "src" attribute.`);
1987
2079
  return;
1988
2080
  }
1989
2081
  let parsed;
1990
2082
  try {
1991
2083
  parsed = new URL(src);
1992
2084
  } catch {
1993
- addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must be an absolute HTTP(S) URL.`);
2085
+ addDiagnostic(diagnostics, source, token.start, `<${elementName3} src> must be an absolute HTTP(S) URL.`);
1994
2086
  return;
1995
2087
  }
1996
2088
  if (parsed.username || parsed.password)
1997
- addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must not contain URL credentials.`);
2089
+ addDiagnostic(diagnostics, source, token.start, `<${elementName3} src> must not contain URL credentials.`);
1998
2090
  if (parsed.protocol !== "https:" && !(options.allowHttpAudio && parsed.protocol === "http:"))
1999
- addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must use HTTPS.`);
2091
+ addDiagnostic(diagnostics, source, token.start, `<${elementName3} src> must use HTTPS.`);
2000
2092
  const isAllowedOrigin = options.allowedAudioOrigins?.some((allowedOrigin) => {
2001
2093
  try {
2002
2094
  const configured = new URL(allowedOrigin);
@@ -2008,13 +2100,13 @@ function validateAudioSource(token, source, diagnostics, options, elementName2)
2008
2100
  }
2009
2101
  }) ?? false;
2010
2102
  if (options.allowedAudioOrigins && !isAllowedOrigin)
2011
- addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> origin "${parsed.origin}" is not allowed.`);
2103
+ addDiagnostic(diagnostics, source, token.start, `<${elementName3} src> origin "${parsed.origin}" is not allowed.`);
2012
2104
  else if (!isAllowedOrigin && !options.allowExternalAudio)
2013
2105
  addDiagnostic(
2014
2106
  diagnostics,
2015
2107
  source,
2016
2108
  token.start,
2017
- `<${elementName2} src> external origin "${parsed.origin}" is blocked by default; set allowExternalAudio to true or provide allowedAudioOrigins.`
2109
+ `<${elementName3} src> external origin "${parsed.origin}" is blocked by default; set allowExternalAudio to true or provide allowedAudioOrigins.`
2018
2110
  );
2019
2111
  }
2020
2112
  function validateElement(token, source, diagnostics, voiceName, options, voiceCatalog) {
@@ -2332,6 +2424,14 @@ function validateAzureSsml(ssml, options = {}) {
2332
2424
  const diagnostics = validateAzureSsmlStatic(ssml, options);
2333
2425
  const validator = options.urlValidator ?? options.customUrlValidator;
2334
2426
  if (!validator || typeof ssml !== "string") return diagnostics;
2427
+ const runnerOptions = options.urlValidation ?? {};
2428
+ const boundedValidator = createAzureUrlValidatorRunner(validator, {
2429
+ ...runnerOptions,
2430
+ ...options.urlValidatorConcurrency !== void 0 ? { concurrency: options.urlValidatorConcurrency } : {},
2431
+ ...options.urlValidatorTimeoutMs !== void 0 ? { timeoutMs: options.urlValidatorTimeoutMs } : {},
2432
+ ...options.urlValidatorSignal ? { signal: options.urlValidatorSignal } : {},
2433
+ ...options.urlValidatorCache ? { cache: options.urlValidatorCache } : {}
2434
+ });
2335
2435
  let tokens;
2336
2436
  try {
2337
2437
  tokens = tokenizeElements(ssml);
@@ -2341,7 +2441,7 @@ function validateAzureSsml(ssml, options = {}) {
2341
2441
  const checks = tokens.flatMap(
2342
2442
  (token) => urlAttributes(token).map(async ({ attribute, value }) => {
2343
2443
  try {
2344
- const result = await validator(value, { tag: token.name, attribute });
2444
+ const result = await boundedValidator(value, { tag: token.name, attribute });
2345
2445
  const valid = typeof result === "boolean" ? result : result.valid;
2346
2446
  if (!valid) {
2347
2447
  const reason = typeof result === "boolean" ? void 0 : result.reason;
@@ -2394,6 +2494,7 @@ export {
2394
2494
  extractSsmlTranslatableText,
2395
2495
  fromPlainTextToSsml,
2396
2496
  validateSsmlStructureIntegrity,
2497
+ createAzureUrlValidatorRunner,
2397
2498
  isValidAzureAudioDuration,
2398
2499
  normalizeAzureLanguage,
2399
2500
  areAzureLanguagesEquivalent,
@@ -2401,4 +2502,4 @@ export {
2401
2502
  getAzureVoiceCatalogMetadata,
2402
2503
  getBuiltInVoiceCatalogMetadata
2403
2504
  };
2404
- //# sourceMappingURL=chunk-25LOR4AJ.mjs.map
2505
+ //# sourceMappingURL=chunk-AQ55MOPU.mjs.map