ssml-builder-js 2.10.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
@@ -179,7 +179,7 @@ SSML の検証は、構文、Azure 固有の静的な意味、実サービスの
179
179
 
180
180
  `voice`、`prosody`、`break`、`express-as`、`say-as`、`phoneme`、`audio`、`lang`、`mark` に加えて、`mstts:dialog`、`mstts:turn`、`mstts:backgroundaudio`、`mstts:ttsembedding`、`mstts:embedding`、`mstts:voiceconversion` を型付きで表現できます。`type: "custom"` と `name` を指定すれば、未定義の XML 要素や追加属性も扱えます。`mstts:` 要素を含むドキュメントを生成すると、必要な Azure Speech 名前空間が自動的に追加されます。
181
181
 
182
- `mstts:turn` は `voice` またはマルチトーカー用の `speaker` を指定でき、`mstts:ttsembedding` は `speakerProfileId`、`mstts:embedding` は `id`、`mstts:voiceconversion` は `url` と `profile` を専用プロパティで指定できます。`mstts:backgroundaudio` は `<speak>` 直下の先頭要素として 1 文書に 1 つだけ配置します。
182
+ `mstts:turn` は `voice` またはマルチトーカー用の `speaker` を指定でき、`mstts:ttsembedding` は `speakerProfileId`、`mstts:embedding` は `id`、`mstts:voiceconversion` は `url` と `profile` を専用プロパティで指定できます。`mstts:backgroundaudio` は `<speak>` 直下の先頭要素として 1 文書に 1 つだけ配置し、`fadein`/`fadeout` は単位なしの生ミリ秒(0〜10000)で指定します。
183
183
 
184
184
  `validateAzureSsml` は Azure Speech へ送信する前に実行する事前静的チェックです。返却される診断の `source` はパッケージ側の静的解析結果であることを示し、Azure Speech サービス側でのランタイム生成結果や実際の合成可否を表すものではありません。
185
185
 
@@ -208,7 +208,7 @@ const diagnostics = validateAzureSsml(ssml, {
208
208
  });
209
209
  ```
210
210
 
211
- 音声カタログは `AzureVoiceDefinition` の `name`、`locale`、`secondaryLocales`、`styles`、`supportedTags`、`unsupportedTags`、`models` で表現できます。`voiceDefinitions`(または `voiceCatalog`)を渡すと、組み込み台帳を外部定義で補完・上書きできます。`supportedTags`/`unsupportedTags` による音声別制約違反は `azure-unsupported-tag-for-voice` としてエラーになります。組み込み台帳にない音声は `azure-unknown-voice`(`unknownVoicePolicy` に従う)、登録済み音声への非対応スタイルは `azure-unsupported-style`、ロケール不一致は `azure-locale-mismatch` として区別されます。
211
+ 音声カタログは `AzureVoiceDefinition` の `name`、`locale`、`secondaryLocales`、`styles`、`supportedTags`、`unsupportedTags`、`models`、`regions`、`status` で表現できます。`voiceDefinitions`(または `voiceCatalog`)を渡すと、組み込み台帳を外部定義で補完・上書きできます。`supportedTags`/`unsupportedTags` による音声別制約違反は `azure-unsupported-tag-for-voice` としてエラーになります。組み込み台帳にない音声は `azure-unknown-voice`(`unknownVoicePolicy` に従う)、登録済み音声への非対応スタイルは `azure-unsupported-style`、ロケール不一致は `azure-locale-mismatch` として区別されます。
212
212
 
213
213
  `npm run sync:voices -- --regions eastus,japaneast` は Azure List Voices API をリージョンごとに取得し、重複を除いた TypeScript 音声定義と `azureVoiceCatalog.json`(生成日時、API バージョン、リージョン、収録数)を更新します。認証情報は `AZURE_SPEECH_KEY` と `AZURE_SPEECH_REGION(S)`、または CLI オプションで指定します。
214
214
 
@@ -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` の利用方法
@@ -247,6 +260,28 @@ Azure Speech は `<audio>` の URL を取得するため、任意の URL をそ
247
260
 
248
261
  `editMode="visual"` を指定するかツールバーの **Visual** を選ぶと、XML を直接見ずに構造ツリーとパンくずから親要素を選択し、フォームで本文の変更、rate、pitch、emotion、pause、pronunciation の適用ができます。Azure の配置違反や属性エラーはビジュアル領域にも表示されます。`editMode="code"` で Monaco に戻ります。
249
262
 
263
+ Visual Editor と Code Editor の対応要素は次のとおりです。
264
+
265
+ | 要素 | Visual Editor | Code Editor |
266
+ | --- | --- | --- |
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 のフォーム | 完全対応 |
272
+ | `mstts:dialog` / `mstts:turn` | 話者ターンの追加・音声/話者/本文編集 | 完全対応・補完あり |
273
+ | `mstts:backgroundaudio` | URL、音量、フェードイン/アウト編集 | 完全対応・補完あり |
274
+ | `mstts:ttsembedding`、`mstts:embedding`、`mstts:voiceconversion` | profile/id/url のフォーム | 完全対応 |
275
+ | 未定義の XML 要素 | 構造ツリーで保持 | `custom` として編集 |
276
+
277
+ Azure Speech のライフサイクル対応状況は次のとおりです。GA 要素は通常の静的検証対象、プレビュー要素・音声は Warning、非推奨要素・音声は Info の診断を返します。`AzureVoiceDefinition.status` と `AzureValidationOptions.tagStatuses` で外部カタログの状態も指定できます。
278
+
279
+ | 状態 | 診断 | 対象 |
280
+ | --- | --- | --- |
281
+ | GA | なし | 標準 SSML、`mstts:dialog`、`mstts:backgroundaudio` |
282
+ | Preview | Warning | `mstts:voiceconversion`、`previewTags` / `tagStatuses` に指定した要素、`status: "preview"` の音声 |
283
+ | Deprecated | Info | `deprecatedTags` / `tagStatuses`、`status: "deprecated"` の音声 |
284
+
250
285
  `<mstts:express-as>` の `style` 属性補完と標準の感情メニューは、カーソルまたは選択範囲を囲む最内の `<voice name="...">` が対応するスタイルだけを表示します。音声名が未指定の場合は全候補を表示し、登録済みでスタイル非対応の音声や未登録の音声では候補がないことを表示します。`emotionStyles` を指定した場合、感情メニューではその値と登録済み音声の対応スタイルの共通部分を使用します。
251
286
 
252
287
  ```tsx
@@ -283,7 +318,7 @@ export function App() {
283
318
  - `onSsmlChange`: 編集後に生成された SSML 文字列を受け取るコールバック
284
319
  - `ref`: `SsmlEditorRef` の `getFullSsml()` で全体の SSML、`getSelectedSsml()` で選択範囲(未選択時は内容が空でないカーソル行)の SSML、`getCurrentLineSsml()` で現在行の SSML を取得。選択範囲やカーソル行が空の場合は `null` を返します
285
320
  - `onSelectionChange`: 選択テキスト、文字数、選択状態を受け取るコールバック
286
- - `onPreviewSelection`: フローティングアクションの試聴ボタン押下時に、選択部分の SSML を受け取るコールバック。省略時は試聴ボタンが無効になります。Azure などの音声 API はこのコールバックから呼び出してください
321
+ - `onPreviewSelection`: フローティングアクションの試聴ボタン押下時に、選択部分を元の `speak` 属性、`voice`、`prosody` などの親コンテキストで包んだ一時 SSML を受け取るコールバック。省略時は試聴ボタンが無効になります。Azure などの音声 API はこのコールバックから呼び出してください
287
322
  - `editMode`: 初期編集モード(`"code"` または `"visual"`)。Visual モードは構造ツリーとフォームによる構造化編集を提供します
288
323
  - `locale`: 画面表示の言語(`"ja"` または `"en"`)。省略時は `"ja"`。ホバーヘルプを含む UI の翻訳にも使用されます
289
324
  - `language`: `locale` の旧名称。既存コードとの互換性のため利用できますが、新しいコードでは `locale` を使用してください
@@ -349,6 +384,19 @@ const audio = await client.synthesize(ssml);
349
384
 
350
385
  帯域幅を抑える場合は `audio-24khz-48kbitrate-mono-mp3` または `audio-16khz-32kbitrate-mono-mp3` を指定できます。
351
386
 
387
+ 最新の音声一覧は公開 API から取得できます。複数リージョンを指定した場合は音声名で重複排除され、各音声の `regions` と `metadata`(`voiceCount`、`generatedAt`、`apiVersion`、`regions`)が返ります。
388
+
389
+ ```ts
390
+ import { fetchAzureVoiceCatalog } from "ssml-builder-js";
391
+
392
+ const catalog = await fetchAzureVoiceCatalog({
393
+ apiKey: process.env.AZURE_SPEECH_KEY!,
394
+ region: ["eastus", "japaneast"],
395
+ });
396
+ ```
397
+
398
+ CLI では `npx ssml-builder sync-voices --region eastus --output ./azure-voices.json` を実行します。キーは `AZURE_SPEECH_KEY`(または `--key`)、リージョンは `AZURE_SPEECH_REGION(S)`(または `--region(s)`)から指定できます。
399
+
352
400
  ```ts
353
401
  const client = new AzureTtsClient({
354
402
  subscriptionKey: process.env.AZURE_SPEECH_KEY!,
@@ -629,7 +677,7 @@ SSML validation separates XML syntax, Azure-specific static semantics, and runti
629
677
 
630
678
  Typed representations are available for elements such as `voice`, `prosody`, `break`, `express-as`, `say-as`, `phoneme`, `audio`, `lang`, and `mark`, plus `mstts:dialog`, `mstts:turn`, `mstts:backgroundaudio`, `mstts:ttsembedding`, `mstts:embedding`, and `mstts:voiceconversion`. Use `type: "custom"` and `name` to handle undefined XML elements or additional attributes. When a document contains `mstts:` elements, the required Azure Speech namespace is added automatically.
631
679
 
632
- `mstts:turn` accepts either `voice` or the multi-talker `speaker` property. The typed extension properties include `speakerProfileId` for `mstts:ttsembedding`, `id` for `mstts:embedding`, and `url` plus `profile` for `mstts:voiceconversion`. `mstts:backgroundaudio` must be the first direct child element of `<speak>` and may appear only once per document.
680
+ `mstts:turn` accepts either `voice` or the multi-talker `speaker` property. The typed extension properties include `speakerProfileId` for `mstts:ttsembedding`, `id` for `mstts:embedding`, and `url` plus `profile` for `mstts:voiceconversion`. `mstts:backgroundaudio` must be the first direct child element of `<speak>` and may appear only once per document; `fadein` and `fadeout` are raw milliseconds from 0 through 10000.
633
681
 
634
682
  `validateAzureSsml` is a preflight static check performed by this package before sending SSML to Azure Speech. The `source` on each returned diagnostic identifies package-side static analysis; it is independent of Azure Speech's runtime generation result and does not guarantee that synthesis will succeed.
635
683
 
@@ -654,7 +702,7 @@ const diagnostics = validateAzureSsml(ssml, {
654
702
  });
655
703
  ```
656
704
 
657
- The catalog is represented by `AzureVoiceDefinition` with `name`, `locale`, optional `secondaryLocales`, `styles`, `supportedTags`, `unsupportedTags`, and `models`. 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.
705
+ 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.
658
706
 
659
707
  `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.
660
708
 
@@ -693,6 +741,24 @@ Azure Speech fetches `<audio>` URLs, so a server that accepts arbitrary user-pro
693
741
 
694
742
  Set `editMode="visual"`, or choose **Visual** in the toolbar, to edit without viewing XML source. The structured editor provides a structure tree, parent breadcrumb selection, text editing, and form actions for rate, pitch, emotion, pause, and pronunciation. Azure placement and attribute diagnostics are shown in the visual area. Choose `editMode="code"` to return to Monaco.
695
743
 
744
+ The Visual Editor and Code Editor support the following elements:
745
+
746
+ | Element | Visual Editor | Code Editor |
747
+ | --- | --- | --- |
748
+ | `voice`, `prosody`, `break`, `express-as`, `say-as`, `phoneme` | Form actions and structure tree | Full support |
749
+ | `mstts:dialog` / `mstts:turn` | Add turns and edit voice, speaker, and text | Full support with completion |
750
+ | `mstts:backgroundaudio` | Edit URL, volume, fade-in, and fade-out | Full support with completion |
751
+ | `mstts:ttsembedding`, `mstts:embedding`, `mstts:voiceconversion` | Preserved in the structure tree | Full support |
752
+ | Unknown XML elements | Preserved in the structure tree | Editable as `custom` |
753
+
754
+ Azure lifecycle support is reported by static validation: GA elements produce no lifecycle diagnostic, preview elements and voices produce a Warning, and deprecated elements and voices produce an Info diagnostic. Use `AzureVoiceDefinition.status` and `AzureValidationOptions.tagStatuses` for external catalog metadata.
755
+
756
+ | Status | Diagnostic | Examples |
757
+ | --- | --- | --- |
758
+ | GA | None | Standard SSML, `mstts:dialog`, `mstts:backgroundaudio` |
759
+ | Preview | Warning | `mstts:voiceconversion`, tags listed in `previewTags` / `tagStatuses`, voices with `status: "preview"` |
760
+ | Deprecated | Info | Tags listed in `deprecatedTags` / `tagStatuses`, voices with `status: "deprecated"` |
761
+
696
762
  Completion for the `<mstts:express-as>` `style` attribute and the built-in Emotion menu only show styles supported by the innermost `<voice name="...">` around the cursor or selection. When no voice name is available, all candidates are shown; a registered voice without supported styles or an explicitly unregistered voice displays an empty-state message. When `emotionStyles` is supplied, the Emotion menu uses its intersection with the registered voice's supported styles.
697
763
 
698
764
  ```tsx
@@ -795,6 +861,35 @@ const audio = await client.synthesize(ssml);
795
861
 
796
862
  For lower bandwidth, choose `audio-24khz-48kbitrate-mono-mp3` or `audio-16khz-32kbitrate-mono-mp3`.
797
863
 
864
+ Fetch the current voice catalog from Azure with `fetchAzureVoiceCatalog`. Multiple regions are deduplicated by voice name, and each voice includes its available `regions`; the result also contains `metadata` with `voiceCount`, `generatedAt`, `apiVersion`, and `regions`.
865
+
866
+ ```ts
867
+ import { fetchAzureVoiceCatalog } from "ssml-builder-js";
868
+
869
+ const catalog = await fetchAzureVoiceCatalog({
870
+ apiKey: process.env.AZURE_SPEECH_KEY!,
871
+ region: ["eastus", "japaneast"],
872
+ });
873
+ ```
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
+
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)`).
892
+
798
893
  ```ts
799
894
  const client = new AzureTtsClient({
800
895
  subscriptionKey: process.env.AZURE_SPEECH_KEY!,
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { writeFile } = require("node:fs/promises");
4
+ const { resolve } = require("node:path");
5
+ const { fetchAzureVoiceCatalog } = require("../dist/index.js");
6
+
7
+ function usage() {
8
+ return [
9
+ "Usage: ssml-builder sync-voices --region <region[,region...]> --output <file>",
10
+ "",
11
+ "Environment variables: AZURE_SPEECH_KEY, AZURE_SPEECH_REGION(S)",
12
+ ].join("\n");
13
+ }
14
+
15
+ function parseArgs(args) {
16
+ const options = {};
17
+ for (let index = 0; index < args.length; index += 1) {
18
+ const argument = args[index];
19
+ if (argument === "--region" || argument === "--regions") {
20
+ options.regions = (args[++index] || "")
21
+ .split(",")
22
+ .map((value) => value.trim())
23
+ .filter(Boolean);
24
+ } else if (argument === "--output") {
25
+ options.output = args[++index];
26
+ } else if (argument === "--key") {
27
+ options.apiKey = args[++index];
28
+ } else if (argument === "--help" || argument === "-h") {
29
+ console.log(usage());
30
+ process.exit(0);
31
+ } else {
32
+ throw new Error(`Unknown option: ${argument}\n\n${usage()}`);
33
+ }
34
+ }
35
+ return options;
36
+ }
37
+
38
+ async function main() {
39
+ const [command, ...args] = process.argv.slice(2);
40
+ if (command !== "sync-voices") throw new Error(usage());
41
+ const options = parseArgs(args);
42
+ const regions =
43
+ options.regions ||
44
+ (process.env.AZURE_SPEECH_REGIONS || process.env.AZURE_SPEECH_REGION || "")
45
+ .split(",")
46
+ .map((value) => value.trim())
47
+ .filter(Boolean);
48
+ const apiKey = options.apiKey || process.env.AZURE_SPEECH_KEY;
49
+ const output = options.output;
50
+ if (!apiKey) throw new Error("Set AZURE_SPEECH_KEY or pass --key.");
51
+ if (regions.length === 0) throw new Error("Set AZURE_SPEECH_REGION(S) or pass --region(s).");
52
+ if (!output) throw new Error(`Pass --output <file>.\n\n${usage()}`);
53
+
54
+ const catalog = await fetchAzureVoiceCatalog({ apiKey, region: regions });
55
+ await writeFile(resolve(output), `${JSON.stringify(catalog, null, 2)}\n`, "utf8");
56
+ console.log(`Wrote ${catalog.metadata.voiceCount} Azure voice definitions to ${resolve(output)}.`);
57
+ }
58
+
59
+ main().catch((error) => {
60
+ console.error(error instanceof Error ? error.message : String(error));
61
+ process.exitCode = 1;
62
+ });
@@ -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) {
@@ -1446,6 +1544,9 @@ var AZURE_VOICE_DEFINITIONS = [
1446
1544
  },
1447
1545
  { name: "es-ES-ElviraNeural", locale: "es-ES" },
1448
1546
  { name: "fil-PH-AngeloNeural", locale: "fil-PH" },
1547
+ { name: "fil-PH-Angelo:DragonHDLatestNeural", locale: "fil-PH" },
1548
+ { name: "fil-PH-BlessicaNeural", locale: "fil-PH" },
1549
+ { name: "fil-PH-Blessica:DragonHDLatestNeural", locale: "fil-PH" },
1449
1550
  { name: "fr-FR-DeniseNeural", locale: "fr-FR", styles: ["cheerful", "sad"] },
1450
1551
  { name: "fr-FR-HenriNeural", locale: "fr-FR", styles: ["cheerful", "sad"] },
1451
1552
  { name: "id-ID-GadisNeural", locale: "id-ID" },
@@ -1542,6 +1643,18 @@ var ALLOWED_SILENCE_TYPES = /* @__PURE__ */ new Set([
1542
1643
  "Enumerationcomma"
1543
1644
  ]);
1544
1645
  var ALLOWED_VISEME_TYPES = /* @__PURE__ */ new Set(["redlips_front", "FacialExpression"]);
1646
+ var DEFAULT_PREVIEW_TAGS = /* @__PURE__ */ new Set(["mstts:voiceconversion"]);
1647
+ function featureStatusForTag(name, options) {
1648
+ const tagName = canonicalTagName(name);
1649
+ const configured = Object.entries(options.tagStatuses ?? {}).find(
1650
+ ([candidate]) => canonicalTagName(candidate) === tagName
1651
+ )?.[1];
1652
+ if (configured) return configured;
1653
+ if ((options.previewTags ?? [...DEFAULT_PREVIEW_TAGS]).some((candidate) => canonicalTagName(candidate) === tagName))
1654
+ return "preview";
1655
+ if ((options.deprecatedTags ?? []).some((candidate) => canonicalTagName(candidate) === tagName)) return "deprecated";
1656
+ return void 0;
1657
+ }
1545
1658
  function decodeAttribute(value) {
1546
1659
  return value.replace(
1547
1660
  /&(?:amp|apos|gt|lt|quot);/gi,
@@ -1610,6 +1723,7 @@ function tokenizeElements(source) {
1610
1723
  attributes,
1611
1724
  childElementIndex,
1612
1725
  end,
1726
+ depth: openElements.length + 1,
1613
1727
  name: tokenName,
1614
1728
  parentName: parent?.name,
1615
1729
  parentVoiceName,
@@ -1658,9 +1772,9 @@ function isValidAzureAudioDuration(value) {
1658
1772
  return Number(clock[1]) > 0 || Number(clock[2]) > 0 || Number(clock[3]) > 0 || Number(clock[4] ?? 0) > 0;
1659
1773
  }
1660
1774
  function isValidAzureBackgroundAudioDuration(value) {
1661
- const match = /^(\d+(?:\.\d+)?)(ms|s)?$/i.exec(value.trim());
1775
+ const match = /^(\d+)$/.exec(value.trim());
1662
1776
  if (!match) return false;
1663
- const milliseconds = Number(match[1]) * (match[2]?.toLowerCase() === "s" ? 1e3 : 1);
1777
+ const milliseconds = Number(match[1]);
1664
1778
  return Number.isFinite(milliseconds) && milliseconds >= 0 && milliseconds <= 1e4;
1665
1779
  }
1666
1780
  function attr(token, name) {
@@ -1800,13 +1914,18 @@ function validateAudioSource(token, source, diagnostics, options, elementName2)
1800
1914
  addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must be an absolute HTTP(S) URL.`);
1801
1915
  return;
1802
1916
  }
1917
+ if (parsed.username || parsed.password)
1918
+ addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must not contain URL credentials.`);
1803
1919
  if (parsed.protocol !== "https:" && !(options.allowHttpAudio && parsed.protocol === "http:"))
1804
1920
  addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must use HTTPS.`);
1805
1921
  const isAllowedOrigin = options.allowedAudioOrigins?.some((allowedOrigin) => {
1806
1922
  try {
1807
- 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;
1808
1927
  } catch {
1809
- return allowedOrigin === parsed.origin;
1928
+ return false;
1810
1929
  }
1811
1930
  }) ?? false;
1812
1931
  if (options.allowedAudioOrigins && !isAllowedOrigin)
@@ -1821,6 +1940,25 @@ function validateAudioSource(token, source, diagnostics, options, elementName2)
1821
1940
  }
1822
1941
  function validateElement(token, source, diagnostics, voiceName, options, voiceCatalog) {
1823
1942
  const name = token.name.toLowerCase();
1943
+ const tagStatus = featureStatusForTag(token.name, options);
1944
+ if (tagStatus === "preview")
1945
+ addDiagnostic(
1946
+ diagnostics,
1947
+ source,
1948
+ token.start,
1949
+ `<${token.name}> is an Azure Speech preview feature and may change or require preview access.`,
1950
+ "warning",
1951
+ "azure-preview-tag"
1952
+ );
1953
+ if (tagStatus === "deprecated")
1954
+ addDiagnostic(
1955
+ diagnostics,
1956
+ source,
1957
+ token.start,
1958
+ `<${token.name}> is deprecated by Azure Speech; migrate to a supported alternative.`,
1959
+ "info",
1960
+ "azure-deprecated-tag"
1961
+ );
1824
1962
  if (name === "voice" && !attr(token, "name")?.trim())
1825
1963
  addDiagnostic(diagnostics, source, token.start, '<voice> requires a non-empty "name" attribute.');
1826
1964
  if (name === "break") {
@@ -1995,6 +2133,9 @@ function validateAzureSsml(ssml, options = {}) {
1995
2133
  const maxLength = options.maxLength ?? 1e4;
1996
2134
  if (ssml.length > maxLength)
1997
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
+ }
1998
2139
  try {
1999
2140
  parseSsml(ssml);
2000
2141
  } catch (error) {
@@ -2004,6 +2145,18 @@ function validateAzureSsml(ssml, options = {}) {
2004
2145
  return diagnostics;
2005
2146
  }
2006
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
+ }
2007
2160
  const speak = tokens.find((token) => token.name.toLowerCase() === "speak");
2008
2161
  const voices = tokens.filter((token) => token.name.toLowerCase() === "voice");
2009
2162
  const backgroundAudioTokens = tokens.filter((token) => token.name.toLowerCase() === "mstts:backgroundaudio");
@@ -2032,6 +2185,24 @@ function validateAzureSsml(ssml, options = {}) {
2032
2185
  const name = attr(token, "name")?.trim();
2033
2186
  const language = attr(token, "xml:lang")?.trim() || (speak ? attr(speak, "xml:lang")?.trim() : void 0);
2034
2187
  const definition = name ? voiceCatalog.get(name.toLowerCase()) : void 0;
2188
+ if (name && definition?.status === "preview")
2189
+ addDiagnostic(
2190
+ diagnostics,
2191
+ ssml,
2192
+ token.start,
2193
+ `Voice "${name}" is an Azure Speech preview voice and may change or require preview access.`,
2194
+ "warning",
2195
+ "azure-preview-voice"
2196
+ );
2197
+ if (name && definition?.status === "deprecated")
2198
+ addDiagnostic(
2199
+ diagnostics,
2200
+ ssml,
2201
+ token.start,
2202
+ `Voice "${name}" is deprecated by Azure Speech; migrate to a supported voice.`,
2203
+ "info",
2204
+ "azure-deprecated-voice"
2205
+ );
2035
2206
  if (name && !definition && policySeverity)
2036
2207
  addDiagnostic(
2037
2208
  diagnostics,
@@ -2092,6 +2263,7 @@ export {
2092
2263
  buildSsml,
2093
2264
  parseSsml,
2094
2265
  buildPartialSsml,
2266
+ splitSsmlDocument,
2095
2267
  validateSsml,
2096
2268
  extractSsmlText,
2097
2269
  mapSsmlTextNodes,
@@ -2105,4 +2277,4 @@ export {
2105
2277
  getAzureVoiceCatalogMetadata,
2106
2278
  getBuiltInVoiceCatalogMetadata
2107
2279
  };
2108
- //# sourceMappingURL=chunk-GW6CKHXF.mjs.map
2280
+ //# sourceMappingURL=chunk-4BVNAUVR.mjs.map