ssml-builder-js 2.11.0 → 2.12.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
@@ -236,9 +236,22 @@ const diagnostics = validateAzureSsml(ssml, {
236
236
  customVoiceStyleMap: { "my-custom-voice": ["narration"] },
237
237
  unknownVoicePolicy: "error", // "error" | "warn" | "ignore"
238
238
  allowedAudioOrigins: ["https://cdn.example.com"],
239
+ maxXmlDepth: 24,
239
240
  });
240
241
  ```
241
242
 
243
+ `maxXmlDepth` は `<speak>` を深さ 1 として XML の過剰なネストを検出します。長文は `splitSsmlDocument` で `<p>`/`<s>` と親の `voice`、`prosody` コンテキストを保ったまま分割できます。
244
+
245
+ ```ts
246
+ import { splitSsmlDocument } from "ssml-builder-js/core";
247
+
248
+ const blocks = splitSsmlDocument(longSsml, 10_000);
249
+ for (const block of blocks) {
250
+ // 各 block は独立した <speak> 文書として Azure へ送信できます。
251
+ await client.synthesize(block);
252
+ }
253
+ ```
254
+
242
255
  Azure Speech は `<audio>` の URL を取得するため、任意の URL をそのまま受け付けるサーバーは SSRF の踏み台になり得ます。ユーザー入力の SSML を合成する場合は、HTTPS、許可オリジン、リダイレクト先、応答サイズをサーバー側でも制限し、`allowExternalAudio` だけで無制限に許可しないでください。
243
256
 
244
257
  ## `ssml-editor-react` の利用方法
@@ -251,10 +264,14 @@ Visual Editor と Code Editor の対応要素は次のとおりです。
251
264
 
252
265
  | 要素 | Visual Editor | Code Editor |
253
266
  | --- | --- | --- |
254
- | `voice`、`prosody`、`break`、`express-as`、`say-as`、`phoneme` | フォーム操作・構造ツリー | 完全対応 |
267
+ | `voice`、`prosody` | 音声名/effect、rate/pitch/volume/contour/range のフォーム | 完全対応 |
268
+ | `say-as`、`phoneme` | 属性フォーム・構造ツリー | 完全対応 |
269
+ | `audio` | URL、説明、clip、速度、繰り返し、音量のフォーム | 完全対応 |
270
+ | `mark`、`bookmark` | 名前のフォーム | 完全対応 |
271
+ | `mstts:silence`、`mstts:audioduration` | 種別/value・duration のフォーム | 完全対応 |
255
272
  | `mstts:dialog` / `mstts:turn` | 話者ターンの追加・音声/話者/本文編集 | 完全対応・補完あり |
256
273
  | `mstts:backgroundaudio` | URL、音量、フェードイン/アウト編集 | 完全対応・補完あり |
257
- | `mstts:ttsembedding`、`mstts:embedding`、`mstts:voiceconversion` | 構造ツリーで保持 | 完全対応 |
274
+ | `mstts:ttsembedding`、`mstts:embedding`、`mstts:voiceconversion` | profile/id/url のフォーム | 完全対応 |
258
275
  | 未定義の XML 要素 | 構造ツリーで保持 | `custom` として編集 |
259
276
 
260
277
  Azure Speech のライフサイクル対応状況は次のとおりです。GA 要素は通常の静的検証対象、プレビュー要素・音声は Warning、非推奨要素・音声は Info の診断を返します。`AzureVoiceDefinition.status` と `AzureValidationOptions.tagStatuses` で外部カタログの状態も指定できます。
@@ -301,7 +318,7 @@ export function App() {
301
318
  - `onSsmlChange`: 編集後に生成された SSML 文字列を受け取るコールバック
302
319
  - `ref`: `SsmlEditorRef` の `getFullSsml()` で全体の SSML、`getSelectedSsml()` で選択範囲(未選択時は内容が空でないカーソル行)の SSML、`getCurrentLineSsml()` で現在行の SSML を取得。選択範囲やカーソル行が空の場合は `null` を返します
303
320
  - `onSelectionChange`: 選択テキスト、文字数、選択状態を受け取るコールバック
304
- - `onPreviewSelection`: フローティングアクションの試聴ボタン押下時に、選択部分の SSML を受け取るコールバック。省略時は試聴ボタンが無効になります。Azure などの音声 API はこのコールバックから呼び出してください
321
+ - `onPreviewSelection`: フローティングアクションの試聴ボタン押下時に、選択部分を元の `speak` 属性、`voice`、`prosody` などの親コンテキストで包んだ一時 SSML を受け取るコールバック。省略時は試聴ボタンが無効になります。Azure などの音声 API はこのコールバックから呼び出してください
305
322
  - `editMode`: 初期編集モード(`"code"` または `"visual"`)。Visual モードは構造ツリーとフォームによる構造化編集を提供します
306
323
  - `locale`: 画面表示の言語(`"ja"` または `"en"`)。省略時は `"ja"`。ホバーヘルプを含む UI の翻訳にも使用されます
307
324
  - `language`: `locale` の旧名称。既存コードとの互換性のため利用できますが、新しいコードでは `locale` を使用してください
@@ -855,6 +872,22 @@ const catalog = await fetchAzureVoiceCatalog({
855
872
  });
856
873
  ```
857
874
 
875
+ 同期情報が必要な場合は `synthesizeSsml`(または `AzureTtsClient.synthesizeSsml`)を使用します。SDK の 100 ナノ秒単位のオフセットはミリ秒へ変換されます。
876
+
877
+ ```ts
878
+ import { synthesizeSsml } from "ssml-builder-js";
879
+
880
+ const result = await synthesizeSsml(ssml, {
881
+ subscriptionKey: process.env.AZURE_SPEECH_KEY!,
882
+ region: process.env.AZURE_SPEECH_REGION!,
883
+ });
884
+ result.audioData; // ArrayBuffer
885
+ result.durationMs;
886
+ result.boundaries; // { text, audioOffsetMs, durationMs }[]
887
+ result.visemes; // { visemeId, audioOffsetMs }[]
888
+ result.bookmarks; // { name, audioOffsetMs }[]
889
+ ```
890
+
858
891
  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)`).
859
892
 
860
893
  ```ts
@@ -931,6 +931,104 @@ function buildPartialSsml(textOrOptions, context) {
931
931
  return serializePartialSsml(textOrOptions.text, textOrOptions);
932
932
  }
933
933
 
934
+ // packages/ssml-core/src/split.ts
935
+ var DEFAULT_MAX_LENGTH = 1e4;
936
+ function cloneElement(element, children) {
937
+ return { ...element, children };
938
+ }
939
+ function documentWithChildren(document, children) {
940
+ return buildSsml({ ...document, children });
941
+ }
942
+ function wrapWithContext(node, context) {
943
+ return context.reduceRight((current, parent) => cloneElement(parent, [current]), node);
944
+ }
945
+ function documentWithNode(document, node, context) {
946
+ return documentWithChildren(document, [wrapWithContext(node, context)]);
947
+ }
948
+ function splitTextNode(text, document, context, maxLength) {
949
+ if (!text) return [text];
950
+ const parts = [];
951
+ let start = 0;
952
+ while (start < text.length) {
953
+ let end = start + 1;
954
+ let bestEnd = end;
955
+ while (end <= text.length) {
956
+ if (documentWithNode(document, text.slice(start, end), context).length > maxLength) break;
957
+ bestEnd = end;
958
+ end += 1;
959
+ }
960
+ if (bestEnd === start) {
961
+ throw new RangeError("maxLength is too small to contain the SSML document wrapper");
962
+ }
963
+ const segment = text.slice(start, bestEnd);
964
+ const boundary = Math.max(segment.lastIndexOf(" "), segment.lastIndexOf("\n"), segment.lastIndexOf(" "));
965
+ const splitEnd = boundary > 0 ? start + boundary + 1 : bestEnd;
966
+ parts.push(text.slice(start, splitEnd));
967
+ start = splitEnd;
968
+ }
969
+ return parts;
970
+ }
971
+ function splitNode(document, node, maxLength, context = []) {
972
+ if (typeof node === "string" || node.type === "text") {
973
+ const value = typeof node === "string" ? node : node.value;
974
+ if (documentWithNode(document, node, context).length <= maxLength) return [node];
975
+ return splitTextNode(value, document, context, maxLength);
976
+ }
977
+ if (documentWithNode(document, node, context).length <= maxLength) return [node];
978
+ const children = node.children ?? [];
979
+ if (children.length === 0) {
980
+ throw new RangeError(`maxLength is too small to contain <${node.type}>`);
981
+ }
982
+ const splitChildren = children.flatMap((child) => splitNode(document, child, maxLength, [...context, node]));
983
+ const parts = [];
984
+ let group = [];
985
+ const flush = () => {
986
+ if (group.length > 0) {
987
+ parts.push(cloneElement(node, group));
988
+ group = [];
989
+ }
990
+ };
991
+ for (const child of splitChildren) {
992
+ const candidate = cloneElement(node, [...group, child]);
993
+ if (documentWithNode(document, candidate, context).length <= maxLength) {
994
+ group.push(child);
995
+ continue;
996
+ }
997
+ flush();
998
+ if (documentWithNode(document, cloneElement(node, [child]), context).length > maxLength) {
999
+ throw new RangeError(`maxLength is too small to contain <${node.type}>`);
1000
+ }
1001
+ group.push(child);
1002
+ }
1003
+ flush();
1004
+ return parts;
1005
+ }
1006
+ function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH) {
1007
+ if (!Number.isInteger(maxLength) || maxLength <= 0) {
1008
+ throw new RangeError("maxLength must be a positive integer");
1009
+ }
1010
+ 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));
1014
+ const chunks = [];
1015
+ let group = [];
1016
+ for (const child of splitChildren) {
1017
+ const candidate = [...group, child];
1018
+ if (documentWithChildren(document, candidate).length <= maxLength) {
1019
+ group = candidate;
1020
+ continue;
1021
+ }
1022
+ if (group.length > 0) chunks.push(group);
1023
+ group = [child];
1024
+ if (documentWithChildren(document, group).length > maxLength) {
1025
+ throw new RangeError("maxLength is too small to contain the SSML document wrapper");
1026
+ }
1027
+ }
1028
+ if (group.length > 0) chunks.push(group);
1029
+ return chunks.map((chunk) => documentWithChildren(document, chunk));
1030
+ }
1031
+
934
1032
  // packages/ssml-core/src/validation.ts
935
1033
  var PARSER_POSITION_SUFFIX = / at position (\d+)$/;
936
1034
  function validateSsml(xmlString) {
@@ -1625,6 +1723,7 @@ function tokenizeElements(source) {
1625
1723
  attributes,
1626
1724
  childElementIndex,
1627
1725
  end,
1726
+ depth: openElements.length + 1,
1628
1727
  name: tokenName,
1629
1728
  parentName: parent?.name,
1630
1729
  parentVoiceName,
@@ -1815,13 +1914,18 @@ function validateAudioSource(token, source, diagnostics, options, elementName2)
1815
1914
  addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must be an absolute HTTP(S) URL.`);
1816
1915
  return;
1817
1916
  }
1917
+ if (parsed.username || parsed.password)
1918
+ addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must not contain URL credentials.`);
1818
1919
  if (parsed.protocol !== "https:" && !(options.allowHttpAudio && parsed.protocol === "http:"))
1819
1920
  addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must use HTTPS.`);
1820
1921
  const isAllowedOrigin = options.allowedAudioOrigins?.some((allowedOrigin) => {
1821
1922
  try {
1822
- return new URL(allowedOrigin).origin === parsed.origin;
1923
+ const configured = new URL(allowedOrigin);
1924
+ if (configured.protocol !== "https:" && configured.protocol !== "http:" || configured.username || configured.password || configured.pathname !== "/" || configured.search || configured.hash)
1925
+ return false;
1926
+ return configured.origin === parsed.origin;
1823
1927
  } catch {
1824
- return allowedOrigin === parsed.origin;
1928
+ return false;
1825
1929
  }
1826
1930
  }) ?? false;
1827
1931
  if (options.allowedAudioOrigins && !isAllowedOrigin)
@@ -2029,6 +2133,9 @@ function validateAzureSsml(ssml, options = {}) {
2029
2133
  const maxLength = options.maxLength ?? 1e4;
2030
2134
  if (ssml.length > maxLength)
2031
2135
  addDiagnostic(diagnostics, ssml, maxLength, `SSML exceeds the maximum length of ${maxLength} characters.`);
2136
+ if (options.maxXmlDepth !== void 0 && (!Number.isInteger(options.maxXmlDepth) || options.maxXmlDepth <= 0)) {
2137
+ addDiagnostic(diagnostics, ssml, 0, "maxXmlDepth must be a positive integer.");
2138
+ }
2032
2139
  try {
2033
2140
  parseSsml(ssml);
2034
2141
  } catch (error) {
@@ -2038,6 +2145,18 @@ function validateAzureSsml(ssml, options = {}) {
2038
2145
  return diagnostics;
2039
2146
  }
2040
2147
  const tokens = tokenizeElements(ssml);
2148
+ if (options.maxXmlDepth !== void 0) {
2149
+ for (const token of tokens) {
2150
+ if (token.depth > options.maxXmlDepth) {
2151
+ addDiagnostic(
2152
+ diagnostics,
2153
+ ssml,
2154
+ token.start,
2155
+ `XML nesting depth ${token.depth} exceeds the configured maximum of ${options.maxXmlDepth}.`
2156
+ );
2157
+ }
2158
+ }
2159
+ }
2041
2160
  const speak = tokens.find((token) => token.name.toLowerCase() === "speak");
2042
2161
  const voices = tokens.filter((token) => token.name.toLowerCase() === "voice");
2043
2162
  const backgroundAudioTokens = tokens.filter((token) => token.name.toLowerCase() === "mstts:backgroundaudio");
@@ -2144,6 +2263,7 @@ export {
2144
2263
  buildSsml,
2145
2264
  parseSsml,
2146
2265
  buildPartialSsml,
2266
+ splitSsmlDocument,
2147
2267
  validateSsml,
2148
2268
  extractSsmlText,
2149
2269
  mapSsmlTextNodes,
@@ -2157,4 +2277,4 @@ export {
2157
2277
  getAzureVoiceCatalogMetadata,
2158
2278
  getBuiltInVoiceCatalogMetadata
2159
2279
  };
2160
- //# sourceMappingURL=chunk-SNRI43QJ.mjs.map
2280
+ //# sourceMappingURL=chunk-4BVNAUVR.mjs.map