ssml-builder-js 2.12.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 +34 -1
- package/dist/{chunk-4BVNAUVR.mjs → chunk-AQ55MOPU.mjs} +246 -21
- package/dist/chunk-AQ55MOPU.mjs.map +1 -0
- package/dist/chunk-QFIBPCO4.mjs +1816 -0
- package/dist/chunk-QFIBPCO4.mjs.map +1 -0
- package/dist/{chunk-FHDFRM2F.mjs → chunk-UZSCXPC5.mjs} +248 -1722
- package/dist/chunk-UZSCXPC5.mjs.map +1 -0
- package/dist/core.d.mts +62 -3
- package/dist/core.d.ts +62 -3
- package/dist/core.js +246 -20
- package/dist/core.js.map +1 -1
- package/dist/core.mjs +3 -1
- package/dist/elements.js +196 -8
- package/dist/elements.js.map +1 -1
- package/dist/elements.mjs +6 -4
- package/dist/elements.mjs.map +1 -1
- package/dist/index.d-CUXRwSw0.d.mts +232 -0
- package/dist/index.d-CUXRwSw0.d.ts +232 -0
- package/dist/index.d.mts +151 -2
- package/dist/index.d.ts +151 -2
- package/dist/index.js +2264 -41
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +479 -4
- package/dist/index.mjs.map +1 -1
- package/dist/react.d.mts +65 -163
- package/dist/react.d.ts +65 -163
- package/dist/react.js +498 -22
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +162 -18
- package/dist/react.mjs.map +1 -1
- package/package.json +3 -2
- package/dist/chunk-4BVNAUVR.mjs.map +0 -1
- package/dist/chunk-FHDFRM2F.mjs.map +0 -1
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,18 @@ 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
|
+
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
|
+
|
|
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.
|
|
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
|
+
|
|
891
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)`).
|
|
892
925
|
|
|
893
926
|
```ts
|
|
@@ -1003,30 +1003,138 @@ function splitNode(document, node, maxLength, context = []) {
|
|
|
1003
1003
|
flush();
|
|
1004
1004
|
return parts;
|
|
1005
1005
|
}
|
|
1006
|
-
function
|
|
1007
|
-
if (
|
|
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 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
|
+
}
|
|
1065
|
+
function createChunk(document, nodes, chunkIndex, textStart, backgroundAudio, replicateBackgroundAudio) {
|
|
1066
|
+
const chunkNodes = backgroundAudio && (replicateBackgroundAudio || chunkIndex === 0) ? [backgroundAudio, ...nodes] : nodes;
|
|
1067
|
+
const text = nodes.map(textFromNode).join("");
|
|
1068
|
+
const marks = [];
|
|
1069
|
+
for (const node of nodes) collectMarks(node, marks);
|
|
1070
|
+
const inheritedContext = collectInheritedContext(nodes);
|
|
1071
|
+
if (inheritedContext.lang === void 0 && document.lang) inheritedContext.lang = document.lang;
|
|
1072
|
+
return {
|
|
1073
|
+
chunkIndex,
|
|
1074
|
+
ssml: documentWithChildren(document, chunkNodes),
|
|
1075
|
+
originalTextRange: { start: textStart, end: textStart + text.length },
|
|
1076
|
+
inheritedContext,
|
|
1077
|
+
containedMarks: marks,
|
|
1078
|
+
hasBackgroundAudio: chunkNodes.some(
|
|
1079
|
+
(node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
|
|
1080
|
+
),
|
|
1081
|
+
sourceNodePath: findSourceNodePath(document.children ?? [], textStart)
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
function splitSsmlDocument(ssml, maxLength = DEFAULT_MAX_LENGTH, options = {}) {
|
|
1085
|
+
const resolvedMaxLength = typeof maxLength === "number" ? maxLength : maxLength.maxLength ?? DEFAULT_MAX_LENGTH;
|
|
1086
|
+
const resolvedOptions = typeof maxLength === "number" ? options : maxLength;
|
|
1087
|
+
if (!Number.isInteger(resolvedMaxLength) || resolvedMaxLength <= 0) {
|
|
1008
1088
|
throw new RangeError("maxLength must be a positive integer");
|
|
1009
1089
|
}
|
|
1010
1090
|
const document = parseSsml(ssml);
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1091
|
+
const backgroundAudio = (document.children ?? []).find(
|
|
1092
|
+
(node) => typeof node !== "string" && node.type !== "text" && node.type === "mstts:backgroundaudio"
|
|
1093
|
+
);
|
|
1094
|
+
if (ssml.length <= resolvedMaxLength) {
|
|
1095
|
+
return [createChunk(document, document.children ?? [], 0, 0, backgroundAudio, true)];
|
|
1096
|
+
}
|
|
1097
|
+
const contentChildren = (document.children ?? []).filter((node) => node !== backgroundAudio);
|
|
1098
|
+
const plainDocumentLength = documentWithChildren(document, []).length;
|
|
1099
|
+
const backgroundDocumentLength = backgroundAudio ? documentWithChildren(document, [backgroundAudio]).length : plainDocumentLength;
|
|
1100
|
+
const backgroundOverhead = Math.max(0, backgroundDocumentLength - plainDocumentLength);
|
|
1101
|
+
const contentMaxLength = Math.max(1, resolvedMaxLength - backgroundOverhead);
|
|
1102
|
+
const splitChildren = contentChildren.flatMap((child) => splitNode(document, child, contentMaxLength));
|
|
1014
1103
|
const chunks = [];
|
|
1015
1104
|
let group = [];
|
|
1016
1105
|
for (const child of splitChildren) {
|
|
1017
1106
|
const candidate = [...group, child];
|
|
1018
|
-
if (documentWithChildren(document, candidate).length <=
|
|
1107
|
+
if (documentWithChildren(document, candidate).length <= contentMaxLength) {
|
|
1019
1108
|
group = candidate;
|
|
1020
1109
|
continue;
|
|
1021
1110
|
}
|
|
1022
1111
|
if (group.length > 0) chunks.push(group);
|
|
1023
1112
|
group = [child];
|
|
1024
|
-
if (documentWithChildren(document, group).length >
|
|
1113
|
+
if (documentWithChildren(document, group).length > contentMaxLength) {
|
|
1025
1114
|
throw new RangeError("maxLength is too small to contain the SSML document wrapper");
|
|
1026
1115
|
}
|
|
1027
1116
|
}
|
|
1028
1117
|
if (group.length > 0) chunks.push(group);
|
|
1029
|
-
|
|
1118
|
+
if (chunks.length === 0) {
|
|
1119
|
+
const result = createChunk(document, [], 0, 0, backgroundAudio, resolvedOptions.replicateBackgroundAudio ?? false);
|
|
1120
|
+
if (result.ssml.length > resolvedMaxLength) {
|
|
1121
|
+
throw new RangeError("maxLength is too small to contain the SSML document wrapper");
|
|
1122
|
+
}
|
|
1123
|
+
return [result];
|
|
1124
|
+
}
|
|
1125
|
+
let textStart = 0;
|
|
1126
|
+
return chunks.map((chunk, chunkIndex) => {
|
|
1127
|
+
const result = createChunk(
|
|
1128
|
+
document,
|
|
1129
|
+
chunk,
|
|
1130
|
+
chunkIndex,
|
|
1131
|
+
textStart,
|
|
1132
|
+
backgroundAudio,
|
|
1133
|
+
resolvedOptions.replicateBackgroundAudio ?? false
|
|
1134
|
+
);
|
|
1135
|
+
textStart = result.originalTextRange.end;
|
|
1136
|
+
return result;
|
|
1137
|
+
});
|
|
1030
1138
|
}
|
|
1031
1139
|
|
|
1032
1140
|
// packages/ssml-core/src/validation.ts
|
|
@@ -1196,7 +1304,7 @@ async function mapSsmlTextNodes(ssml, transform, options = {}) {
|
|
|
1196
1304
|
|
|
1197
1305
|
// packages/ssml-core/src/migration.ts
|
|
1198
1306
|
var DEFAULT_TRANSLATION_SKIP_TAGS = ["phoneme", "say-as", "sayAs", "sub"];
|
|
1199
|
-
function
|
|
1307
|
+
function elementName2(element) {
|
|
1200
1308
|
switch (element.type) {
|
|
1201
1309
|
case "custom":
|
|
1202
1310
|
case "element":
|
|
@@ -1349,7 +1457,7 @@ function extractSsmlTranslatableText(ssml, options = {}) {
|
|
|
1349
1457
|
}
|
|
1350
1458
|
return;
|
|
1351
1459
|
}
|
|
1352
|
-
const tag =
|
|
1460
|
+
const tag = elementName2(node);
|
|
1353
1461
|
if (skipTags.has(tag.toLowerCase())) return;
|
|
1354
1462
|
visit(childrenOf(node), [...ancestors, tag], [...path, String(index)]);
|
|
1355
1463
|
});
|
|
@@ -1395,7 +1503,7 @@ function serializeDocument2(document) {
|
|
|
1395
1503
|
const serialize = (node) => {
|
|
1396
1504
|
if (typeof node === "string") return node.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1397
1505
|
if (node.type === "text") return serialize(node.value);
|
|
1398
|
-
const tag =
|
|
1506
|
+
const tag = elementName2(node);
|
|
1399
1507
|
const nodeAttributes = elementAttributes(node);
|
|
1400
1508
|
const serializedAttributes = Object.entries(nodeAttributes).map(([name, value]) => ` ${name}="${serialize(value).replace(/"/g, """)}"`).join("");
|
|
1401
1509
|
const children = childrenOf(node).map(serialize).join("");
|
|
@@ -1409,7 +1517,7 @@ function flatten(document) {
|
|
|
1409
1517
|
nodes.forEach((node, index) => {
|
|
1410
1518
|
if (typeof node === "string" || node.type === "text") return;
|
|
1411
1519
|
const currentPath = `${path}/${index}`;
|
|
1412
|
-
result.push({ name:
|
|
1520
|
+
result.push({ name: elementName2(node), attributes: elementAttributes(node), path: currentPath });
|
|
1413
1521
|
visit(childrenOf(node), currentPath);
|
|
1414
1522
|
});
|
|
1415
1523
|
};
|
|
@@ -1607,6 +1715,69 @@ var AZURE_VOICE_DEFINITIONS = [
|
|
|
1607
1715
|
];
|
|
1608
1716
|
|
|
1609
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
|
+
}
|
|
1610
1781
|
var ALLOWED_BREAK_STRENGTHS = /* @__PURE__ */ new Set(["none", "x-weak", "weak", "medium", "strong", "x-strong"]);
|
|
1611
1782
|
var ALLOWED_SAY_AS = /* @__PURE__ */ new Set([
|
|
1612
1783
|
"characters",
|
|
@@ -1901,23 +2072,23 @@ function validateVoiceFeatureMatrix(token, source, diagnostics, voiceName, defin
|
|
|
1901
2072
|
);
|
|
1902
2073
|
}
|
|
1903
2074
|
}
|
|
1904
|
-
function validateAudioSource(token, source, diagnostics, options,
|
|
2075
|
+
function validateAudioSource(token, source, diagnostics, options, elementName3) {
|
|
1905
2076
|
const src = attr(token, "src");
|
|
1906
2077
|
if (!src) {
|
|
1907
|
-
addDiagnostic(diagnostics, source, token.start, `<${
|
|
2078
|
+
addDiagnostic(diagnostics, source, token.start, `<${elementName3}> requires a "src" attribute.`);
|
|
1908
2079
|
return;
|
|
1909
2080
|
}
|
|
1910
2081
|
let parsed;
|
|
1911
2082
|
try {
|
|
1912
2083
|
parsed = new URL(src);
|
|
1913
2084
|
} catch {
|
|
1914
|
-
addDiagnostic(diagnostics, source, token.start, `<${
|
|
2085
|
+
addDiagnostic(diagnostics, source, token.start, `<${elementName3} src> must be an absolute HTTP(S) URL.`);
|
|
1915
2086
|
return;
|
|
1916
2087
|
}
|
|
1917
2088
|
if (parsed.username || parsed.password)
|
|
1918
|
-
addDiagnostic(diagnostics, source, token.start, `<${
|
|
2089
|
+
addDiagnostic(diagnostics, source, token.start, `<${elementName3} src> must not contain URL credentials.`);
|
|
1919
2090
|
if (parsed.protocol !== "https:" && !(options.allowHttpAudio && parsed.protocol === "http:"))
|
|
1920
|
-
addDiagnostic(diagnostics, source, token.start, `<${
|
|
2091
|
+
addDiagnostic(diagnostics, source, token.start, `<${elementName3} src> must use HTTPS.`);
|
|
1921
2092
|
const isAllowedOrigin = options.allowedAudioOrigins?.some((allowedOrigin) => {
|
|
1922
2093
|
try {
|
|
1923
2094
|
const configured = new URL(allowedOrigin);
|
|
@@ -1929,13 +2100,13 @@ function validateAudioSource(token, source, diagnostics, options, elementName2)
|
|
|
1929
2100
|
}
|
|
1930
2101
|
}) ?? false;
|
|
1931
2102
|
if (options.allowedAudioOrigins && !isAllowedOrigin)
|
|
1932
|
-
addDiagnostic(diagnostics, source, token.start, `<${
|
|
2103
|
+
addDiagnostic(diagnostics, source, token.start, `<${elementName3} src> origin "${parsed.origin}" is not allowed.`);
|
|
1933
2104
|
else if (!isAllowedOrigin && !options.allowExternalAudio)
|
|
1934
2105
|
addDiagnostic(
|
|
1935
2106
|
diagnostics,
|
|
1936
2107
|
source,
|
|
1937
2108
|
token.start,
|
|
1938
|
-
`<${
|
|
2109
|
+
`<${elementName3} src> external origin "${parsed.origin}" is blocked by default; set allowExternalAudio to true or provide allowedAudioOrigins.`
|
|
1939
2110
|
);
|
|
1940
2111
|
}
|
|
1941
2112
|
function validateElement(token, source, diagnostics, voiceName, options, voiceCatalog) {
|
|
@@ -2117,7 +2288,7 @@ function validateElement(token, source, diagnostics, voiceName, options, voiceCa
|
|
|
2117
2288
|
addDiagnostic(diagnostics, source, token.start, "<mstts:backgroundaudio> must be self-closing.");
|
|
2118
2289
|
}
|
|
2119
2290
|
}
|
|
2120
|
-
function
|
|
2291
|
+
function validateAzureSsmlStatic(ssml, options = {}) {
|
|
2121
2292
|
const diagnostics = [];
|
|
2122
2293
|
if (typeof ssml !== "string") {
|
|
2123
2294
|
return [
|
|
@@ -2241,6 +2412,59 @@ function validateAzureSsml(ssml, options = {}) {
|
|
|
2241
2412
|
}
|
|
2242
2413
|
return diagnostics;
|
|
2243
2414
|
}
|
|
2415
|
+
function urlAttributes(token) {
|
|
2416
|
+
const tag = canonicalTagName(token.name);
|
|
2417
|
+
const attributes = tag === "audio" || tag === "mstts:backgroundaudio" ? ["src"] : tag === "lexicon" ? ["uri"] : tag === "mstts:voiceconversion" ? ["url"] : [];
|
|
2418
|
+
return attributes.flatMap((attribute) => {
|
|
2419
|
+
const value = attr(token, attribute);
|
|
2420
|
+
return value === void 0 ? [] : [{ attribute, value }];
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
function validateAzureSsml(ssml, options = {}) {
|
|
2424
|
+
const diagnostics = validateAzureSsmlStatic(ssml, options);
|
|
2425
|
+
const validator = options.urlValidator ?? options.customUrlValidator;
|
|
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
|
+
});
|
|
2435
|
+
let tokens;
|
|
2436
|
+
try {
|
|
2437
|
+
tokens = tokenizeElements(ssml);
|
|
2438
|
+
} catch {
|
|
2439
|
+
return diagnostics;
|
|
2440
|
+
}
|
|
2441
|
+
const checks = tokens.flatMap(
|
|
2442
|
+
(token) => urlAttributes(token).map(async ({ attribute, value }) => {
|
|
2443
|
+
try {
|
|
2444
|
+
const result = await boundedValidator(value, { tag: token.name, attribute });
|
|
2445
|
+
const valid = typeof result === "boolean" ? result : result.valid;
|
|
2446
|
+
if (!valid) {
|
|
2447
|
+
const reason = typeof result === "boolean" ? void 0 : result.reason;
|
|
2448
|
+
addDiagnostic(
|
|
2449
|
+
diagnostics,
|
|
2450
|
+
ssml,
|
|
2451
|
+
token.start,
|
|
2452
|
+
`<${token.name} ${attribute}> was rejected by the custom URL validator${reason ? `: ${reason}` : "."}`
|
|
2453
|
+
);
|
|
2454
|
+
}
|
|
2455
|
+
} catch (error) {
|
|
2456
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
2457
|
+
addDiagnostic(
|
|
2458
|
+
diagnostics,
|
|
2459
|
+
ssml,
|
|
2460
|
+
token.start,
|
|
2461
|
+
`<${token.name} ${attribute}> could not be validated by the custom URL validator: ${reason}`
|
|
2462
|
+
);
|
|
2463
|
+
}
|
|
2464
|
+
})
|
|
2465
|
+
);
|
|
2466
|
+
return Promise.all(checks).then(() => diagnostics);
|
|
2467
|
+
}
|
|
2244
2468
|
|
|
2245
2469
|
// packages/ssml-core/src/generated/azureVoiceCatalog.ts
|
|
2246
2470
|
var AZURE_VOICE_CATALOG_METADATA = {
|
|
@@ -2270,6 +2494,7 @@ export {
|
|
|
2270
2494
|
extractSsmlTranslatableText,
|
|
2271
2495
|
fromPlainTextToSsml,
|
|
2272
2496
|
validateSsmlStructureIntegrity,
|
|
2497
|
+
createAzureUrlValidatorRunner,
|
|
2273
2498
|
isValidAzureAudioDuration,
|
|
2274
2499
|
normalizeAzureLanguage,
|
|
2275
2500
|
areAzureLanguagesEquivalent,
|
|
@@ -2277,4 +2502,4 @@ export {
|
|
|
2277
2502
|
getAzureVoiceCatalogMetadata,
|
|
2278
2503
|
getBuiltInVoiceCatalogMetadata
|
|
2279
2504
|
};
|
|
2280
|
-
//# sourceMappingURL=chunk-
|
|
2505
|
+
//# sourceMappingURL=chunk-AQ55MOPU.mjs.map
|