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 +26 -1
- package/dist/{chunk-4BVNAUVR.mjs → chunk-25LOR4AJ.mjs} +134 -10
- package/dist/chunk-25LOR4AJ.mjs.map +1 -0
- package/dist/chunk-CZ2F3TET.mjs +1745 -0
- package/dist/chunk-CZ2F3TET.mjs.map +1 -0
- package/dist/{chunk-FHDFRM2F.mjs → chunk-LCTE26LS.mjs} +248 -1722
- package/dist/chunk-LCTE26LS.mjs.map +1 -0
- package/dist/core.d.mts +45 -3
- package/dist/core.d.ts +45 -3
- package/dist/core.js +133 -9
- package/dist/core.js.map +1 -1
- package/dist/core.mjs +1 -1
- package/dist/elements.js +118 -1
- package/dist/elements.js.map +1 -1
- package/dist/elements.mjs +6 -4
- package/dist/elements.mjs.map +1 -1
- package/dist/index.d-Xbr6ZWnK.d.mts +214 -0
- package/dist/index.d-Xbr6ZWnK.d.ts +214 -0
- package/dist/index.d.mts +95 -2
- package/dist/index.d.ts +95 -2
- package/dist/index.js +1732 -30
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +135 -4
- package/dist/index.mjs.map +1 -1
- package/dist/react.d.mts +61 -163
- package/dist/react.d.ts +61 -163
- package/dist/react.js +397 -15
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +139 -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,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
|
|
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 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
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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 <=
|
|
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 >
|
|
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
|
-
|
|
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
|
|
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-
|
|
2404
|
+
//# sourceMappingURL=chunk-25LOR4AJ.mjs.map
|