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/README.md CHANGED
@@ -240,6 +240,8 @@ const diagnostics = validateAzureSsml(ssml, {
240
240
  });
241
241
  ```
242
242
 
243
+ `audio`、`mstts:backgroundaudio`、`lexicon`、`mstts:voiceconversion` の URL は `urlValidator`(または `customUrlValidator`)へ渡せます。Promise を返す検証関数で DNS 解決後のプライベート IP 遮断などを実装できます。
244
+
243
245
  `maxXmlDepth` は `<speak>` を深さ 1 として XML の過剰なネストを検出します。長文は `splitSsmlDocument` で `<p>`/`<s>` と親の `voice`、`prosody` コンテキストを保ったまま分割できます。
244
246
 
245
247
  ```ts
@@ -248,10 +250,12 @@ import { splitSsmlDocument } from "ssml-builder-js/core";
248
250
  const blocks = splitSsmlDocument(longSsml, 10_000);
249
251
  for (const block of blocks) {
250
252
  // 各 block は独立した <speak> 文書として Azure へ送信できます。
251
- await client.synthesize(block);
253
+ await client.synthesize(block.ssml);
252
254
  }
253
255
  ```
254
256
 
257
+ 各ブロックは `SsmlChunk`(`chunkIndex`、`originalTextRange`、継承した音声/言語/韻律、内包マーク、背景音声の有無)です。`<mstts:backgroundaudio>` はデフォルトで先頭チャンクだけに含まれ、全チャンクへ複製する場合は `splitSsmlDocument(longSsml, 10_000, { replicateBackgroundAudio: true })` を指定します。
258
+
255
259
  Azure Speech は `<audio>` の URL を取得するため、任意の URL をそのまま受け付けるサーバーは SSRF の踏み台になり得ます。ユーザー入力の SSML を合成する場合は、HTTPS、許可オリジン、リダイレクト先、応答サイズをサーバー側でも制限し、`allowExternalAudio` だけで無制限に許可しないでください。
256
260
 
257
261
  ## `ssml-editor-react` の利用方法
@@ -336,6 +340,8 @@ export function App() {
336
340
  - `insertionGroups`: 挿入メニューを縦線で区切るグループ設定。`toolbarGroups` を省略した場合は、この設定からツールバーの既定グループも作成されます。省略時は間・無音、声の調整、表現、読み上げに分けて表示されます
337
341
  - `emotionStyles`: `emotion` メニューに表示する音声スタイル候補
338
342
  - `customInsertions` / `additionalInsertions`: カスタム SSML 挿入定義。`customInsertions` は同じ ID の標準定義を置き換え、`additionalInsertions` は標準定義へ追加します
343
+ - `customInspectors`: Visual Editor のタグ名ごとに Inspector を差し替えるレンダラー
344
+ - `renderVoiceSelector`: 音声セレクターのカスタムレンダラー。`voiceCatalog` と `voiceLocale`、`voiceRegion`、`voiceStyle` で候補を絞り込めます。プレビュー音声にはバッジ情報も渡されます
339
345
  - `className` / `style`: エディター全体のクラス名とインラインスタイル
340
346
  - `toolbarClassName` / `toolbarStyle`: ツールバーのクラス名とインラインスタイル
341
347
  - `displayClassName` / `displayStyle`: 本文表示エリアのクラス名とインラインスタイル
@@ -702,6 +708,8 @@ const diagnostics = validateAzureSsml(ssml, {
702
708
  });
703
709
  ```
704
710
 
711
+ URLs in `audio`, `mstts:backgroundaudio`, `lexicon`, and `mstts:voiceconversion` can be passed to `urlValidator` (or `customUrlValidator`). The callback may be asynchronous, making it suitable for host-side DNS/private-IP and SSRF policy checks.
712
+
705
713
  The catalog is represented by `AzureVoiceDefinition` with `name`, `locale`, optional `secondaryLocales`, `styles`, `supportedTags`, `unsupportedTags`, `models`, `regions`, and `status`. Pass `voiceDefinitions` (or `voiceCatalog`) to supplement or override the built-in catalog with an external definition. A tag that violates `supportedTags` or `unsupportedTags` produces `azure-unsupported-tag-for-voice` with error severity. `customVoiceStyleMap` remains supported for backward compatibility and overrides styles for the named voice. Diagnostics distinguish an unregistered voice (`azure-unknown-voice`, controlled by `unknownVoicePolicy`), an unsupported style on a registered voice (`azure-unsupported-style`), and a locale mismatch (`azure-locale-mismatch`). The `<mstts:audioduration value="10s"/>` element accepts positive `ms` or `s` values and `hh:mm:ss[.fff]` clock values.
706
714
 
707
715
  `npm run sync:voices -- --regions eastus,japaneast` fetches Azure's List Voices API for each region, deduplicates the results, and updates the generated TypeScript definitions plus `azureVoiceCatalog.json` with generation time, API version, regions, and voice count. Provide credentials through `AZURE_SPEECH_KEY` and `AZURE_SPEECH_REGION(S)`, or CLI options.
@@ -735,6 +743,17 @@ const diagnostics = validateAzureSsml(ssml, {
735
743
 
736
744
  Azure Speech fetches `<audio>` URLs, so a server that accepts arbitrary user-provided SSML can become an SSRF proxy. For untrusted SSML, enforce HTTPS, an origin allowlist, redirect policy, and response-size limits on the server; do not treat `allowExternalAudio` as a substitute for those controls.
737
745
 
746
+ Long documents can be split into independently synthesizable `SsmlChunk` values:
747
+
748
+ ```ts
749
+ import { splitSsmlDocument } from "ssml-builder-js/core";
750
+
751
+ const chunks = splitSsmlDocument(longSsml, 10_000);
752
+ for (const chunk of chunks) await client.synthesize(chunk.ssml);
753
+ ```
754
+
755
+ Each chunk includes `chunkIndex`, `originalTextRange`, inherited voice/language/prosody context, contained markers, and background-audio state. Background audio is placed in the first chunk by default; pass `{ replicateBackgroundAudio: true }` as the third argument to include it in every chunk.
756
+
738
757
  ## Using `ssml-editor-react`
739
758
 
740
759
  `SsmlEditor` accepts an `SsmlDocument` and renders only a toolbar and text display area. The toolbar applies rate, volume, and pitch settings to the selection and provides undo and redo actions. The application is responsible for selecting and displaying the voice. Monaco Editor is used for text editing, and SSML syntax is validated whenever the text changes. Syntax errors are shown with editor markers and an error message. Hovering over XML tag names or parameters shows SSML descriptions. Selecting text displays a floating action bar with the character count and preview action. When `enableCodeLens` is enabled (the default), CodeLens quick controls for editing and unwrapping `prosody` tags and editing or deleting `break` tags are shown above those tags. When `showDecorations` is enabled, inline badges for pause and pitch changes are rendered next to `break` and `prosody` tags, and Monaco inline decorations are enabled. Generated SSML is provided through `onSsmlChange` so the application can display it wherever it needs. Pass an `SsmlEditorRef` through `ref` to retrieve full, selected, or current-line SSML, and use `onSelectionChange` to observe selection text and state. The UI supports Japanese (the default) and English.
@@ -813,6 +832,8 @@ export function App() {
813
832
  - `insertionGroups`: Groups insertion menus with vertical separators; when `toolbarGroups` is omitted, these groups also define the default toolbar groups. Menus are grouped into pauses, voice, expression, and pronunciation by default
814
833
  - `emotionStyles`: Candidate voice styles shown by the `emotion` menu
815
834
  - `customInsertions` / `additionalInsertions`: Custom SSML insertion definitions. `customInsertions` replaces a built-in definition with the same ID, while `additionalInsertions` adds definitions to the built-ins
835
+ - `customInspectors`: Custom renderers keyed by element type or serialized tag name for the Visual Editor
836
+ - `renderVoiceSelector`: Custom voice selector renderer. Supply `voiceCatalog` and optional `voiceLocale`, `voiceRegion`, or `voiceStyle` filters; preview status is included in each voice entry
816
837
  - `className` / `style`: A class name and inline styles for the editor container
817
838
  - `toolbarClassName` / `toolbarStyle`: A class name and inline styles for the toolbar
818
839
  - `displayClassName` / `displayStyle`: A class name and inline styles for the text display area
@@ -888,6 +909,10 @@ result.visemes; // { visemeId, audioOffsetMs }[]
888
909
  result.bookmarks; // { name, audioOffsetMs }[]
889
910
  ```
890
911
 
912
+ 長文を分割して合成する場合は `synthesizeSsmlChunks` または `AzureTtsClient.synthesizeChunks` を使います。音声バイナリを連結し、`boundaries`、`visemes`、`bookmarks` のオフセットを累積 `durationMs` 分だけ補正します。`synthesizeSsmlSafe(client, ssml, { validation })` は検証エラー時に Azure API を呼び出さず、`status: "validation-error"` / `"azure-api-error"` / `"success"` の結果を返します。
913
+
914
+ 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
+
891
916
  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)`).
892
917
 
893
918
  ```ts
@@ -1003,30 +1003,109 @@ function splitNode(document, node, maxLength, context = []) {
1003
1003
  flush();
1004
1004
  return parts;
1005
1005
  }
1006
- function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH) {
1007
- if (!Number.isInteger(maxLength) || maxLength <= 0) {
1006
+ function textFromNode(node) {
1007
+ if (typeof node === "string") return node;
1008
+ if (node.type === "text") return node.value;
1009
+ return (node.children ?? []).map(textFromNode).join("");
1010
+ }
1011
+ function collectMarks(node, marks) {
1012
+ if (typeof node === "string" || node.type === "text") return;
1013
+ if (node.type === "mark" && node.name) marks.push(node.name);
1014
+ if (node.type === "bookmark" && node.mark) marks.push(node.mark);
1015
+ for (const child of node.children ?? []) collectMarks(child, marks);
1016
+ }
1017
+ function collectInheritedContext(nodes) {
1018
+ const context = {};
1019
+ const visit = (node) => {
1020
+ if (typeof node === "string" || node.type === "text") return;
1021
+ if (context.voice === void 0 && node.type === "voice" && node.name) context.voice = node.name;
1022
+ if (context.lang === void 0 && node.type === "lang" && node.lang) context.lang = node.lang;
1023
+ if (context.prosody === void 0 && node.type === "prosody") {
1024
+ const prosody = {};
1025
+ for (const [key, value] of Object.entries(node.attributes ?? {})) prosody[key] = String(value);
1026
+ for (const key of ["rate", "pitch", "volume", "contour", "range"]) {
1027
+ const value = node[key];
1028
+ if (value !== void 0) prosody[key] = String(value);
1029
+ }
1030
+ if (Object.keys(prosody).length > 0) context.prosody = prosody;
1031
+ }
1032
+ for (const child of node.children ?? []) visit(child);
1033
+ };
1034
+ nodes.forEach(visit);
1035
+ return context;
1036
+ }
1037
+ function createChunk(document, nodes, chunkIndex, textStart, backgroundAudio, replicateBackgroundAudio) {
1038
+ const chunkNodes = backgroundAudio && (replicateBackgroundAudio || chunkIndex === 0) ? [backgroundAudio, ...nodes] : nodes;
1039
+ const text = nodes.map(textFromNode).join("");
1040
+ const marks = [];
1041
+ for (const node of nodes) collectMarks(node, marks);
1042
+ const inheritedContext = collectInheritedContext(nodes);
1043
+ if (inheritedContext.lang === void 0 && document.lang) inheritedContext.lang = document.lang;
1044
+ return {
1045
+ chunkIndex,
1046
+ ssml: documentWithChildren(document, chunkNodes),
1047
+ originalTextRange: { start: textStart, end: textStart + text.length },
1048
+ inheritedContext,
1049
+ containedMarks: marks,
1050
+ hasBackgroundAudio: chunkNodes.some(
1051
+ (node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
1052
+ )
1053
+ };
1054
+ }
1055
+ function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH, options = {}) {
1056
+ const resolvedMaxLength = typeof maxLength === "number" ? maxLength : maxLength.maxLength ?? DEFAULT_MAX_LENGTH;
1057
+ const resolvedOptions = typeof maxLength === "number" ? options : maxLength;
1058
+ if (!Number.isInteger(resolvedMaxLength) || resolvedMaxLength <= 0) {
1008
1059
  throw new RangeError("maxLength must be a positive integer");
1009
1060
  }
1010
1061
  const document = parseSsml(ssml);
1011
- if (ssml.length <= maxLength) return [ssml];
1012
- const children = document.children ?? [];
1013
- const splitChildren = children.flatMap((child) => splitNode(document, child, maxLength));
1062
+ const backgroundAudio = (document.children ?? []).find(
1063
+ (node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
1064
+ );
1065
+ if (ssml.length <= resolvedMaxLength) {
1066
+ return [createChunk(document, document.children ?? [], 0, 0, backgroundAudio, true)];
1067
+ }
1068
+ const contentChildren = (document.children ?? []).filter((node) => node !== backgroundAudio);
1069
+ const plainDocumentLength = documentWithChildren(document, []).length;
1070
+ const backgroundDocumentLength = backgroundAudio ? documentWithChildren(document, [backgroundAudio]).length : plainDocumentLength;
1071
+ const backgroundOverhead = Math.max(0, backgroundDocumentLength - plainDocumentLength);
1072
+ const contentMaxLength = Math.max(1, resolvedMaxLength - backgroundOverhead);
1073
+ const splitChildren = contentChildren.flatMap((child) => splitNode(document, child, contentMaxLength));
1014
1074
  const chunks = [];
1015
1075
  let group = [];
1016
1076
  for (const child of splitChildren) {
1017
1077
  const candidate = [...group, child];
1018
- if (documentWithChildren(document, candidate).length <= maxLength) {
1078
+ if (documentWithChildren(document, candidate).length <= contentMaxLength) {
1019
1079
  group = candidate;
1020
1080
  continue;
1021
1081
  }
1022
1082
  if (group.length > 0) chunks.push(group);
1023
1083
  group = [child];
1024
- if (documentWithChildren(document, group).length > maxLength) {
1084
+ if (documentWithChildren(document, group).length > contentMaxLength) {
1025
1085
  throw new RangeError("maxLength is too small to contain the SSML document wrapper");
1026
1086
  }
1027
1087
  }
1028
1088
  if (group.length > 0) chunks.push(group);
1029
- return chunks.map((chunk) => documentWithChildren(document, chunk));
1089
+ if (chunks.length === 0) {
1090
+ const result = createChunk(document, [], 0, 0, backgroundAudio, resolvedOptions.replicateBackgroundAudio ?? false);
1091
+ if (result.ssml.length > resolvedMaxLength) {
1092
+ throw new RangeError("maxLength is too small to contain the SSML document wrapper");
1093
+ }
1094
+ return [result];
1095
+ }
1096
+ let textStart = 0;
1097
+ return chunks.map((chunk, chunkIndex) => {
1098
+ const result = createChunk(
1099
+ document,
1100
+ chunk,
1101
+ chunkIndex,
1102
+ textStart,
1103
+ backgroundAudio,
1104
+ resolvedOptions.replicateBackgroundAudio ?? false
1105
+ );
1106
+ textStart = result.originalTextRange.end;
1107
+ return result;
1108
+ });
1030
1109
  }
1031
1110
 
1032
1111
  // packages/ssml-core/src/validation.ts
@@ -2117,7 +2196,7 @@ function validateElement(token, source, diagnostics, voiceName, options, voiceCa
2117
2196
  addDiagnostic(diagnostics, source, token.start, "<mstts:backgroundaudio> must be self-closing.");
2118
2197
  }
2119
2198
  }
2120
- function validateAzureSsml(ssml, options = {}) {
2199
+ function validateAzureSsmlStatic(ssml, options = {}) {
2121
2200
  const diagnostics = [];
2122
2201
  if (typeof ssml !== "string") {
2123
2202
  return [
@@ -2241,6 +2320,51 @@ function validateAzureSsml(ssml, options = {}) {
2241
2320
  }
2242
2321
  return diagnostics;
2243
2322
  }
2323
+ function urlAttributes(token) {
2324
+ const tag = canonicalTagName(token.name);
2325
+ const attributes = tag === "audio" || tag === "mstts:backgroundaudio" ? ["src"] : tag === "lexicon" ? ["uri"] : tag === "mstts:voiceconversion" ? ["url"] : [];
2326
+ return attributes.flatMap((attribute) => {
2327
+ const value = attr(token, attribute);
2328
+ return value === void 0 ? [] : [{ attribute, value }];
2329
+ });
2330
+ }
2331
+ function validateAzureSsml(ssml, options = {}) {
2332
+ const diagnostics = validateAzureSsmlStatic(ssml, options);
2333
+ const validator = options.urlValidator ?? options.customUrlValidator;
2334
+ if (!validator || typeof ssml !== "string") return diagnostics;
2335
+ let tokens;
2336
+ try {
2337
+ tokens = tokenizeElements(ssml);
2338
+ } catch {
2339
+ return diagnostics;
2340
+ }
2341
+ const checks = tokens.flatMap(
2342
+ (token) => urlAttributes(token).map(async ({ attribute, value }) => {
2343
+ try {
2344
+ const result = await validator(value, { tag: token.name, attribute });
2345
+ const valid = typeof result === "boolean" ? result : result.valid;
2346
+ if (!valid) {
2347
+ const reason = typeof result === "boolean" ? void 0 : result.reason;
2348
+ addDiagnostic(
2349
+ diagnostics,
2350
+ ssml,
2351
+ token.start,
2352
+ `<${token.name} ${attribute}> was rejected by the custom URL validator${reason ? `: ${reason}` : "."}`
2353
+ );
2354
+ }
2355
+ } catch (error) {
2356
+ const reason = error instanceof Error ? error.message : String(error);
2357
+ addDiagnostic(
2358
+ diagnostics,
2359
+ ssml,
2360
+ token.start,
2361
+ `<${token.name} ${attribute}> could not be validated by the custom URL validator: ${reason}`
2362
+ );
2363
+ }
2364
+ })
2365
+ );
2366
+ return Promise.all(checks).then(() => diagnostics);
2367
+ }
2244
2368
 
2245
2369
  // packages/ssml-core/src/generated/azureVoiceCatalog.ts
2246
2370
  var AZURE_VOICE_CATALOG_METADATA = {
@@ -2277,4 +2401,4 @@ export {
2277
2401
  getAzureVoiceCatalogMetadata,
2278
2402
  getBuiltInVoiceCatalogMetadata
2279
2403
  };
2280
- //# sourceMappingURL=chunk-4BVNAUVR.mjs.map
2404
+ //# sourceMappingURL=chunk-25LOR4AJ.mjs.map