ssml-builder-js 2.4.0 → 2.5.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 +149 -3
- package/dist/{chunk-RUIEMCWP.mjs → chunk-4RHHW33A.mjs} +111 -15
- package/dist/chunk-4RHHW33A.mjs.map +1 -0
- package/dist/{chunk-I3BR2WCQ.mjs → chunk-ALFYHSCR.mjs} +1 -1
- package/dist/chunk-ALFYHSCR.mjs.map +1 -0
- package/dist/core.d.mts +18 -4
- package/dist/core.d.ts +18 -4
- package/dist/core.js +110 -14
- package/dist/core.js.map +1 -1
- package/dist/core.mjs +1 -1
- package/dist/elements.js.map +1 -1
- package/dist/elements.mjs +1 -1
- package/dist/index.d.mts +5 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +138 -16
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +29 -3
- package/dist/index.mjs.map +1 -1
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +1 -1
- package/package.json +3 -3
- package/dist/chunk-I3BR2WCQ.mjs.map +0 -1
- package/dist/chunk-RUIEMCWP.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -36,6 +36,8 @@ React エディタを使用する場合は、React と Monaco Editor のアダ
|
|
|
36
36
|
npm install ssml-builder-js @monaco-editor/react react react-dom
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
+
ルートパッケージの React peer dependency は `>=18.2.0 <20` です。React 18.3.1 を使う Next.js の Pages Router / App Router アプリでは、この範囲に一致するため依存関係警告は発生しません。Monaco を使う `SsmlEditor` はクライアントコンポーネントとして配置してください。
|
|
40
|
+
|
|
39
41
|
Web Component を使用する場合は、Monaco Editor もインストールします。
|
|
40
42
|
|
|
41
43
|
```sh
|
|
@@ -133,13 +135,35 @@ const parsed = parseSsml(ssml);
|
|
|
133
135
|
| `parseSsml(xml)` | `<speak>` XML を `SsmlDocument` に変換 |
|
|
134
136
|
| `validateSsml(xml)` | SSML の構文エラーを `{ message, position }` または `null` で返す |
|
|
135
137
|
| `extractSsmlText(xml)` | タグを除いた全テキストノードを文書順に抽出 |
|
|
136
|
-
| `mapSsmlTextNodes(xml, transform)` |
|
|
138
|
+
| `mapSsmlTextNodes(xml, transform, options?)` | タグ構造を維持したままテキストノードだけを同期・非同期変換。スキップタグとコンテキストフィルターに対応 |
|
|
137
139
|
| `validateAzureSsml(xml, options?)` | Azure Speech 向けの意味検証結果を Diagnostic 配列で返す |
|
|
138
140
|
|
|
139
141
|
`voice`、`prosody`、`break`、`express-as`、`say-as`、`phoneme`、`audio`、`lang`、`mark` などの要素を型付きで表現できます。`type: "custom"` と `name` を指定すれば、未定義の XML 要素や追加属性も扱えます。`mstts:` 要素を含むドキュメントを生成すると、必要な Azure Speech 名前空間が自動的に追加されます。
|
|
140
142
|
|
|
141
143
|
翻訳などで本文だけを置き換える場合は、`mapSsmlTextNodes` に変換関数を渡します。変換関数には直近の親タグと祖先タグの `path` が渡され、戻り値は `string` または `Promise<string>` を指定できます。`validateAzureSsml` は音声、属性値、音声スタイル、文字数、`audio` URL/オリジンを検証します。
|
|
142
144
|
|
|
145
|
+
`mapSsmlTextNodes` の第 3 引数で、翻訳対象外タグとコンテキストフィルターを指定できます。デフォルトでは `phoneme`、`say-as`、`sub` の本文を変換しません。`filter` には `parentTag`、`parentAttributes`、`ancestorTags`、`path` が渡されます。
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
const translated = await mapSsmlTextNodes(ssml, translate, {
|
|
149
|
+
skipTags: ["phoneme", "say-as", "sub", "custom-no-translate"],
|
|
150
|
+
filter: ({ parentAttributes, ancestorTags }) =>
|
|
151
|
+
ancestorTags.includes("voice") && parentAttributes["xml:lang"] !== "ja-JP",
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`validateAzureSsml` は `AzureValidationOptions` で音声・スタイルの定義を追加できます。`unknownVoicePolicy` のデフォルトは `"warn"`、`validateNestedVoices` のデフォルトは `true` です。`audio` の外部 URL はデフォルトで拒否されるため、利用する場合は `allowedAudioOrigins` に許可するオリジンを列挙するか、構成を理解した上で `allowExternalAudio: true` を指定してください。
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
const diagnostics = validateAzureSsml(ssml, {
|
|
159
|
+
customVoiceStyleMap: { "my-custom-voice": ["narration"] },
|
|
160
|
+
unknownVoicePolicy: "error", // "error" | "warn" | "ignore"
|
|
161
|
+
allowedAudioOrigins: ["https://cdn.example.com"],
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Azure Speech は `<audio>` の URL を取得するため、任意の URL をそのまま受け付けるサーバーは SSRF の踏み台になり得ます。ユーザー入力の SSML を合成する場合は、HTTPS、許可オリジン、リダイレクト先、応答サイズをサーバー側でも制限し、`allowExternalAudio` だけで無制限に許可しないでください。
|
|
166
|
+
|
|
143
167
|
## `ssml-editor-react` の利用方法
|
|
144
168
|
|
|
145
169
|
`SsmlEditor` は `SsmlDocument` を受け取り、ツールバーと本文の表示エリアだけを表示するシンプルなコンポーネントです。ツールバーから選択範囲の速度、音量、ピッチなどの設定、元に戻す・やり直す操作ができます。音声の選択と表示はアプリ側で行います。本文の編集には Monaco Editor を使用し、変更時に SSML の構文を検証します。構文エラーはエディター上のマーカーとエラーメッセージで表示されます。XML のタグ名やパラメータへホバーすると SSML の説明を確認できます。テキストを選択すると、選択文字数と試聴を行うフローティングアクションが表示されます。`enableCodeLens`(デフォルトは `true`)が有効な場合、`prosody` と `break` タグの上に属性編集やタグ操作の CodeLens が表示されます。`showDecorations` が有効な場合、`break` と `prosody` のタグに間やピッチ変化を示すインラインバッジが表示され、Monaco のインライン装飾も有効になります。生成された SSML は `onSsmlChange` で受け取り、アプリ側で自由に表示できます。`SsmlEditorRef` を `ref` に渡すと、全体、選択範囲、または現在行の SSML を取得できます。画面表示は日本語(デフォルト)と英語に対応しています。
|
|
@@ -243,6 +267,17 @@ const audio = await client.synthesize(ssml);
|
|
|
243
267
|
// audio は audio/mpeg の ArrayBuffer
|
|
244
268
|
```
|
|
245
269
|
|
|
270
|
+
帯域幅を抑える場合は `audio-24khz-48kbitrate-mono-mp3` または `audio-16khz-32kbitrate-mono-mp3` を指定できます。
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
const client = new AzureTtsClient({
|
|
274
|
+
subscriptionKey: process.env.AZURE_SPEECH_KEY!,
|
|
275
|
+
region: process.env.AZURE_SPEECH_REGION!,
|
|
276
|
+
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
|
|
277
|
+
timeoutMs: 15_000,
|
|
278
|
+
});
|
|
279
|
+
```
|
|
280
|
+
|
|
246
281
|
低レベルの `synthesizeSpeech` 関数も利用できます。この関数では `TtsConfig` の
|
|
247
282
|
`endpoint`、`subscriptionKey`、`region` を指定します。
|
|
248
283
|
|
|
@@ -259,6 +294,45 @@ const audio = await synthesizeSpeech(ssml, {
|
|
|
259
294
|
内部では Microsoft Cognitive Services Speech SDK の `SpeechSynthesizer` を使用します。`AzureTtsClient` の `endpoint` を省略すると `https://{region}.tts.speech.microsoft.com/cognitiveservices/v1` が使用されます。上の Playground の例では `.env.example` に合わせて WebSocket エンドポイントを明示しています。独自エンドポイントに `{region}` を含めた場合は、設定したリージョンに置き換えられます。
|
|
260
295
|
`endpoint` に空文字または空白文字列を指定した場合も、リージョンの既定エンドポイントへフォールバックします。`logger` オプションには `debug`、`info`、`warn`、`error` を持つロガーを注入できます。省略時はクライアントからログを出力しません。
|
|
261
296
|
`outputFormat` には Speech SDK がサポートする出力形式を指定できます。省略時は `audio-16khz-128kbitrate-mono-mp3` が使用されます。
|
|
297
|
+
`timeoutMs` は合成を打ち切る時間(ミリ秒)、`signal` はクライアント切断などによるキャンセル用の `AbortSignal` です。リトライする場合は、タイムアウトや一時的な SDK エラーだけを対象にし、同じ `AbortSignal` を渡して指数バックオフを使用してください。
|
|
298
|
+
|
|
299
|
+
Next.js Route Handler ではキーをブラウザへ渡さず、サーバー側で検証・合成します。
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
// app/api/synthesize/route.ts
|
|
303
|
+
import { AzureTtsClient, AzureTtsError } from "@ssml-builder-js/azure-tts-client";
|
|
304
|
+
import { validateAzureSsml, validateSsml } from "@ssml-builder-js/ssml-core";
|
|
305
|
+
|
|
306
|
+
export const runtime = "nodejs";
|
|
307
|
+
|
|
308
|
+
export async function POST(request: Request) {
|
|
309
|
+
const body = (await request.json()) as { ssml?: unknown };
|
|
310
|
+
if (typeof body.ssml !== "string" || validateSsml(body.ssml))
|
|
311
|
+
return Response.json({ error: "Invalid SSML" }, { status: 400 });
|
|
312
|
+
if (validateAzureSsml(body.ssml).some(({ severity }) => severity === "error"))
|
|
313
|
+
return Response.json({ error: "Invalid Azure SSML" }, { status: 400 });
|
|
314
|
+
|
|
315
|
+
const controller = new AbortController();
|
|
316
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
317
|
+
request.signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
318
|
+
try {
|
|
319
|
+
const audio = await new AzureTtsClient({
|
|
320
|
+
subscriptionKey: process.env.AZURE_SPEECH_KEY!,
|
|
321
|
+
region: process.env.AZURE_SPEECH_REGION!,
|
|
322
|
+
signal: controller.signal,
|
|
323
|
+
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
|
|
324
|
+
}).synthesize(body.ssml);
|
|
325
|
+
return new Response(new Uint8Array(audio), { headers: { "Content-Type": "audio/mpeg" } });
|
|
326
|
+
} catch (error) {
|
|
327
|
+
const status = error instanceof AzureTtsError && error.status === 0 ? 504 : 502;
|
|
328
|
+
return Response.json({ error: "Speech synthesis failed" }, { status });
|
|
329
|
+
} finally {
|
|
330
|
+
clearTimeout(timeout);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
リトライを追加する場合は、`AzureTtsSdkError` の内容をログへ出しすぎず、SSML 本文とサブスクリプションキーをログへ書かないでください。外部 `audio` を使う場合も、上記の許可オリジン検証を合成前に実施してください。
|
|
262
336
|
|
|
263
337
|
Speech SDK の合成エラーでは `AzureTtsSdkError`(`AzureTtsError` のサブクラス)がスローされ、`errorDetails` から SDK のエラー詳細を確認できます。SDK が HTTP ステータスやリクエスト ID を公開しないため、SDK 経由のエラーでは `status` は `0`、`statusText` は `"Speech SDK"`、`requestId` は `null` です。Playground のサーバー側ログにもこれらの情報とリージョン、SSML の文字数が出力されます。ログに出力するエラー詳細は 4,096 文字までに制限されます。サブスクリプションキーや SSML 本文自体はログに出力されません。
|
|
264
338
|
|
|
@@ -332,6 +406,8 @@ To use the React editor, also install React and the Monaco Editor adapter:
|
|
|
332
406
|
npm install ssml-builder-js @monaco-editor/react react react-dom
|
|
333
407
|
```
|
|
334
408
|
|
|
409
|
+
The root package accepts React `>=18.2.0 <20` as a peer dependency. Next.js applications using React 18.3.1 work with both the Pages Router and App Router without a peer-dependency range warning. Place the Monaco-based `SsmlEditor` in a client component.
|
|
410
|
+
|
|
335
411
|
To use the Web Component, also install Monaco Editor:
|
|
336
412
|
|
|
337
413
|
```sh
|
|
@@ -429,12 +505,32 @@ The main `buildSsml` and `parseSsml` signatures are:
|
|
|
429
505
|
| `parseSsml(xml)` | Converts a `<speak>` XML document into an `SsmlDocument` |
|
|
430
506
|
| `validateSsml(xml)` | Returns `{ message, position }` for a syntax error, or `null` |
|
|
431
507
|
| `extractSsmlText(xml)` | Extracts all text nodes in document order |
|
|
432
|
-
| `mapSsmlTextNodes(xml, transform)` | Transforms only text nodes while preserving the XML structure; supports sync and
|
|
508
|
+
| `mapSsmlTextNodes(xml, transform, options?)` | Transforms only text nodes while preserving the XML structure; supports sync/async transforms, skipped tags, and context filters |
|
|
433
509
|
| `validateAzureSsml(xml, options?)` | Returns Azure Speech semantic-validation diagnostics |
|
|
434
510
|
|
|
435
511
|
Typed representations are available for elements such as `voice`, `prosody`, `break`, `express-as`, `say-as`, `phoneme`, `audio`, `lang`, and `mark`. 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.
|
|
436
512
|
|
|
437
|
-
Use `mapSsmlTextNodes` to replace translatable content without changing tags, attributes, or nesting. The transform receives the immediate parent tag and ancestor `path`, and may return a `string` or a `Promise<string>`. `
|
|
513
|
+
Use `mapSsmlTextNodes` to replace translatable content without changing tags, attributes, or nesting. The transform receives the immediate parent tag and ancestor `path`, and may return a `string` or a `Promise<string>`. The third argument supports `skipTags` and a `filter` callback; `phoneme`, `say-as`, and `sub` are skipped by default. The callback receives `parentTag`, decoded `parentAttributes`, `ancestorTags`, and `path`.
|
|
514
|
+
|
|
515
|
+
```ts
|
|
516
|
+
const translated = await mapSsmlTextNodes(ssml, translate, {
|
|
517
|
+
skipTags: ["phoneme", "say-as", "sub", "custom-no-translate"],
|
|
518
|
+
filter: ({ parentAttributes, ancestorTags }) =>
|
|
519
|
+
ancestorTags.includes("voice") && parentAttributes["xml:lang"] !== "en-US",
|
|
520
|
+
});
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
`validateAzureSsml` accepts `AzureValidationOptions` for extending the voice/style map. `unknownVoicePolicy` defaults to `"warn"` and `validateNestedVoices` defaults to `true`. External `<audio>` URLs are blocked by default; provide `allowedAudioOrigins` or explicitly set `allowExternalAudio: true` only when the deployment is configured to control those requests.
|
|
524
|
+
|
|
525
|
+
```ts
|
|
526
|
+
const diagnostics = validateAzureSsml(ssml, {
|
|
527
|
+
customVoiceStyleMap: { "my-custom-voice": ["narration"] },
|
|
528
|
+
unknownVoicePolicy: "error", // "error" | "warn" | "ignore"
|
|
529
|
+
allowedAudioOrigins: ["https://cdn.example.com"],
|
|
530
|
+
});
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
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.
|
|
438
534
|
|
|
439
535
|
## Using `ssml-editor-react`
|
|
440
536
|
|
|
@@ -539,6 +635,17 @@ const audio = await client.synthesize(ssml);
|
|
|
539
635
|
// audio is an audio/mpeg ArrayBuffer
|
|
540
636
|
```
|
|
541
637
|
|
|
638
|
+
For lower bandwidth, choose `audio-24khz-48kbitrate-mono-mp3` or `audio-16khz-32kbitrate-mono-mp3`.
|
|
639
|
+
|
|
640
|
+
```ts
|
|
641
|
+
const client = new AzureTtsClient({
|
|
642
|
+
subscriptionKey: process.env.AZURE_SPEECH_KEY!,
|
|
643
|
+
region: process.env.AZURE_SPEECH_REGION!,
|
|
644
|
+
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
|
|
645
|
+
timeoutMs: 15_000,
|
|
646
|
+
});
|
|
647
|
+
```
|
|
648
|
+
|
|
542
649
|
The lower-level `synthesizeSpeech` function is also available. It requires
|
|
543
650
|
`endpoint`, `subscriptionKey`, and `region` in its `TtsConfig` argument.
|
|
544
651
|
|
|
@@ -555,6 +662,45 @@ const audio = await synthesizeSpeech(ssml, {
|
|
|
555
662
|
Internally, the client uses the Microsoft Cognitive Services Speech SDK's `SpeechSynthesizer`. If `endpoint` is omitted from `AzureTtsClient`, `https://{region}.tts.speech.microsoft.com/cognitiveservices/v1` is used. The Playground example explicitly uses the WebSocket endpoint from `.env.example`. If a custom endpoint contains `{region}`, it is replaced with the configured region.
|
|
556
663
|
Empty or whitespace-only `endpoint` values also fall back to the regional endpoint. Pass a `logger` with optional `debug`, `info`, `warn`, and `error` methods to receive client diagnostics; when omitted, the client is silent.
|
|
557
664
|
Set `outputFormat` to a format supported by the Speech SDK. If omitted, `audio-16khz-128kbitrate-mono-mp3` is used.
|
|
665
|
+
`timeoutMs` bounds a synthesis request, while `signal` supports cancellation when a client disconnects. If adding retries, retry only transient SDK failures, pass the same `AbortSignal`, and use exponential backoff.
|
|
666
|
+
|
|
667
|
+
Keep the subscription key on the server by using a Next.js Route Handler (or an equivalent Node.js endpoint):
|
|
668
|
+
|
|
669
|
+
```ts
|
|
670
|
+
// app/api/synthesize/route.ts
|
|
671
|
+
import { AzureTtsClient, AzureTtsError } from "@ssml-builder-js/azure-tts-client";
|
|
672
|
+
import { validateAzureSsml, validateSsml } from "@ssml-builder-js/ssml-core";
|
|
673
|
+
|
|
674
|
+
export const runtime = "nodejs";
|
|
675
|
+
|
|
676
|
+
export async function POST(request: Request) {
|
|
677
|
+
const body = (await request.json()) as { ssml?: unknown };
|
|
678
|
+
if (typeof body.ssml !== "string" || validateSsml(body.ssml))
|
|
679
|
+
return Response.json({ error: "Invalid SSML" }, { status: 400 });
|
|
680
|
+
if (validateAzureSsml(body.ssml).some(({ severity }) => severity === "error"))
|
|
681
|
+
return Response.json({ error: "Invalid Azure SSML" }, { status: 400 });
|
|
682
|
+
|
|
683
|
+
const controller = new AbortController();
|
|
684
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
685
|
+
request.signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
686
|
+
try {
|
|
687
|
+
const audio = await new AzureTtsClient({
|
|
688
|
+
subscriptionKey: process.env.AZURE_SPEECH_KEY!,
|
|
689
|
+
region: process.env.AZURE_SPEECH_REGION!,
|
|
690
|
+
signal: controller.signal,
|
|
691
|
+
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
|
|
692
|
+
}).synthesize(body.ssml);
|
|
693
|
+
return new Response(new Uint8Array(audio), { headers: { "Content-Type": "audio/mpeg" } });
|
|
694
|
+
} catch (error) {
|
|
695
|
+
const status = error instanceof AzureTtsError && error.status === 0 ? 504 : 502;
|
|
696
|
+
return Response.json({ error: "Speech synthesis failed" }, { status });
|
|
697
|
+
} finally {
|
|
698
|
+
clearTimeout(timeout);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
Do not log the SSML body or subscription key. If external `audio` is allowed, validate its origins before synthesis and keep the same SSRF controls in place.
|
|
558
704
|
|
|
559
705
|
Speech SDK synthesis errors throw `AzureTtsSdkError` (a subclass of `AzureTtsError`); its `errorDetails` field contains the SDK error details. Because the SDK does not expose HTTP status or request IDs, SDK errors use `0` for `status`, `"Speech SDK"` for `statusText`, and `null` for `requestId`. The playground's server-side logs include these fields along with the region and SSML character count. Logged error details are limited to 4,096 characters. The subscription key and SSML content itself are not written to logs.
|
|
560
706
|
|
|
@@ -864,6 +864,9 @@ function decodeXmlText(value) {
|
|
|
864
864
|
function encodeXmlText(value) {
|
|
865
865
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
866
866
|
}
|
|
867
|
+
function decodeXmlAttribute(value) {
|
|
868
|
+
return decodeXmlText(value);
|
|
869
|
+
}
|
|
867
870
|
function findTagEnd(source, start) {
|
|
868
871
|
let quote = "";
|
|
869
872
|
for (let index = start; index < source.length; index += 1) {
|
|
@@ -882,14 +885,31 @@ function readTagName(tag) {
|
|
|
882
885
|
const match = /^<\s*([A-Za-z_][A-Za-z0-9_.:-]*)/.exec(tag);
|
|
883
886
|
return match?.[1];
|
|
884
887
|
}
|
|
888
|
+
function readTagAttributes(tag, name) {
|
|
889
|
+
const attributes = {};
|
|
890
|
+
const nameStart = tag.indexOf(name);
|
|
891
|
+
const attributeSource = tag.slice(nameStart + name.length, tag.length - 1).replace(/\/\s*$/, "");
|
|
892
|
+
const attributePattern = /([A-Za-z_][A-Za-z0-9_.:-]*)\s*=\s*(["'])([\s\S]*?)\2/g;
|
|
893
|
+
for (const match of attributeSource.matchAll(attributePattern)) {
|
|
894
|
+
attributes[match[1].toLowerCase()] = decodeXmlAttribute(match[3]);
|
|
895
|
+
}
|
|
896
|
+
return attributes;
|
|
897
|
+
}
|
|
885
898
|
function collectTextNodes(source) {
|
|
886
899
|
const nodes = [];
|
|
887
|
-
const
|
|
900
|
+
const elements = [];
|
|
888
901
|
let index = 0;
|
|
889
902
|
const addText = (start, end, rawText, sourceStart = start, sourceEnd = end) => {
|
|
890
903
|
if (!rawText) return;
|
|
904
|
+
const path = elements.map((element) => element.name);
|
|
905
|
+
const parent = elements[elements.length - 1];
|
|
891
906
|
nodes.push({
|
|
892
|
-
context: {
|
|
907
|
+
context: {
|
|
908
|
+
ancestorTags: path.slice(0, -1),
|
|
909
|
+
parentAttributes: { ...parent?.attributes ?? {} },
|
|
910
|
+
parentTag: parent?.name ?? "",
|
|
911
|
+
path
|
|
912
|
+
},
|
|
893
913
|
decodedText: decodeXmlText(rawText),
|
|
894
914
|
end,
|
|
895
915
|
sourceEnd,
|
|
@@ -931,14 +951,14 @@ function collectTextNodes(source) {
|
|
|
931
951
|
}
|
|
932
952
|
if (source.startsWith("</", index)) {
|
|
933
953
|
const end2 = findTagEnd(source, index + 2);
|
|
934
|
-
|
|
954
|
+
elements.pop();
|
|
935
955
|
index = end2 + 1;
|
|
936
956
|
continue;
|
|
937
957
|
}
|
|
938
958
|
const end = findTagEnd(source, index + 1);
|
|
939
959
|
const tag = source.slice(index, end + 1);
|
|
940
960
|
const name = readTagName(tag);
|
|
941
|
-
if (name && !/\/\s*>$/.test(tag))
|
|
961
|
+
if (name && !/\/\s*>$/.test(tag)) elements.push({ attributes: readTagAttributes(tag, name), name });
|
|
942
962
|
index = end + 1;
|
|
943
963
|
}
|
|
944
964
|
return nodes;
|
|
@@ -947,15 +967,21 @@ function extractSsmlText(ssml) {
|
|
|
947
967
|
parseSsml(ssml);
|
|
948
968
|
return collectTextNodes(ssml).map((node) => node.decodedText);
|
|
949
969
|
}
|
|
950
|
-
async function mapSsmlTextNodes(ssml, transform) {
|
|
970
|
+
async function mapSsmlTextNodes(ssml, transform, options = {}) {
|
|
951
971
|
parseSsml(ssml);
|
|
952
972
|
const nodes = collectTextNodes(ssml);
|
|
973
|
+
const skipTags = new Set((options.skipTags ?? ["phoneme", "say-as", "sub"]).map((tag) => tag.toLowerCase()));
|
|
953
974
|
const replacements = await Promise.all(
|
|
954
975
|
nodes.map(async (node) => {
|
|
955
|
-
const
|
|
976
|
+
const context = {
|
|
977
|
+
ancestorTags: [...node.context.ancestorTags],
|
|
978
|
+
parentAttributes: { ...node.context.parentAttributes },
|
|
956
979
|
parentTag: node.context.parentTag,
|
|
957
980
|
path: [...node.context.path]
|
|
958
|
-
}
|
|
981
|
+
};
|
|
982
|
+
const shouldTransform = !skipTags.has(context.parentTag.toLowerCase()) && (options.filter?.(context) ?? true);
|
|
983
|
+
if (!shouldTransform) return ssml.slice(node.sourceStart, node.sourceEnd);
|
|
984
|
+
const transformed = await transform(node.decodedText, context);
|
|
959
985
|
if (typeof transformed !== "string") {
|
|
960
986
|
throw new TypeError("SSML text node transform must return a string");
|
|
961
987
|
}
|
|
@@ -1112,6 +1138,7 @@ function findTagEnd2(source, start) {
|
|
|
1112
1138
|
}
|
|
1113
1139
|
function tokenizeElements(source) {
|
|
1114
1140
|
const tokens = [];
|
|
1141
|
+
const openElements = [];
|
|
1115
1142
|
let index = 0;
|
|
1116
1143
|
while (index < source.length) {
|
|
1117
1144
|
const start = source.indexOf("<", index);
|
|
@@ -1133,8 +1160,13 @@ function tokenizeElements(source) {
|
|
|
1133
1160
|
}
|
|
1134
1161
|
const end = findTagEnd2(source, start + 1);
|
|
1135
1162
|
const raw = source.slice(start, end + 1);
|
|
1163
|
+
if (raw.startsWith("</")) {
|
|
1164
|
+
openElements.pop();
|
|
1165
|
+
index = end + 1;
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1136
1168
|
const nameMatch = /^<\s*([A-Za-z_][A-Za-z0-9_.:-]*)/.exec(raw);
|
|
1137
|
-
if (!nameMatch?.[1]
|
|
1169
|
+
if (!nameMatch?.[1]) {
|
|
1138
1170
|
index = end + 1;
|
|
1139
1171
|
continue;
|
|
1140
1172
|
}
|
|
@@ -1144,7 +1176,15 @@ function tokenizeElements(source) {
|
|
|
1144
1176
|
for (const match of attributeSource.matchAll(attributePattern)) {
|
|
1145
1177
|
attributes.set(match[1].toLowerCase(), decodeAttribute(match[3]));
|
|
1146
1178
|
}
|
|
1147
|
-
|
|
1179
|
+
const selfClosing = /\/\s*>$/.test(raw);
|
|
1180
|
+
const parentVoiceName = [...openElements].reverse().find((element) => element.voiceName)?.voiceName;
|
|
1181
|
+
tokens.push({ attributes, end, name: nameMatch[1], parentVoiceName, selfClosing, start });
|
|
1182
|
+
if (!selfClosing) {
|
|
1183
|
+
openElements.push({
|
|
1184
|
+
name: nameMatch[1],
|
|
1185
|
+
voiceName: nameMatch[1].toLowerCase() === "voice" ? attributes.get("name") : parentVoiceName
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1148
1188
|
index = end + 1;
|
|
1149
1189
|
}
|
|
1150
1190
|
return tokens;
|
|
@@ -1160,7 +1200,23 @@ function addDiagnostic(diagnostics, source, offset, message, severity = "error")
|
|
|
1160
1200
|
function attr(token, name) {
|
|
1161
1201
|
return token.attributes.get(name.toLowerCase());
|
|
1162
1202
|
}
|
|
1163
|
-
function
|
|
1203
|
+
function normalizeVoiceStyleMap(customVoiceStyleMap) {
|
|
1204
|
+
const map = new Map(
|
|
1205
|
+
Object.entries(EXPRESS_AS_STYLES).map(([voiceName, styles]) => [voiceName.toLowerCase(), styles])
|
|
1206
|
+
);
|
|
1207
|
+
for (const [voiceName, styles] of Object.entries(customVoiceStyleMap ?? {})) {
|
|
1208
|
+
map.set(
|
|
1209
|
+
voiceName.toLowerCase(),
|
|
1210
|
+
styles.map((style) => style.toLowerCase())
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
return map;
|
|
1214
|
+
}
|
|
1215
|
+
function diagnosticSeverity(policy) {
|
|
1216
|
+
if (policy === "ignore") return void 0;
|
|
1217
|
+
return policy === "error" ? "error" : "warning";
|
|
1218
|
+
}
|
|
1219
|
+
function validateElement(token, source, diagnostics, voiceName, options, voiceStyleMap) {
|
|
1164
1220
|
const name = token.name.toLowerCase();
|
|
1165
1221
|
if (name === "voice" && !attr(token, "name")?.trim())
|
|
1166
1222
|
addDiagnostic(diagnostics, source, token.start, '<voice> requires a non-empty "name" attribute.');
|
|
@@ -1202,9 +1258,24 @@ function validateElement(token, source, diagnostics, voiceName, options) {
|
|
|
1202
1258
|
const role = attr(token, "role");
|
|
1203
1259
|
if (role && !ALLOWED_ROLES.has(role))
|
|
1204
1260
|
addDiagnostic(diagnostics, source, token.start, `Unsupported <mstts:express-as role> value "${role}".`);
|
|
1205
|
-
const supportedStyles = voiceName ?
|
|
1206
|
-
|
|
1207
|
-
|
|
1261
|
+
const supportedStyles = voiceName ? voiceStyleMap.get(voiceName.toLowerCase()) : void 0;
|
|
1262
|
+
const severity = diagnosticSeverity(options.unknownVoicePolicy ?? "warn");
|
|
1263
|
+
if (style && supportedStyles && !supportedStyles.includes(style.toLowerCase()) && severity)
|
|
1264
|
+
addDiagnostic(
|
|
1265
|
+
diagnostics,
|
|
1266
|
+
source,
|
|
1267
|
+
token.start,
|
|
1268
|
+
`Unknown style "${style}" is not supported by voice "${voiceName}" according to the configured voice style map.`,
|
|
1269
|
+
severity
|
|
1270
|
+
);
|
|
1271
|
+
if (style && voiceName && !supportedStyles && severity)
|
|
1272
|
+
addDiagnostic(
|
|
1273
|
+
diagnostics,
|
|
1274
|
+
source,
|
|
1275
|
+
token.start,
|
|
1276
|
+
`Unknown style "${style}" cannot be verified because voice "${voiceName}" is not registered in the voice style map.`,
|
|
1277
|
+
severity
|
|
1278
|
+
);
|
|
1208
1279
|
}
|
|
1209
1280
|
if (name === "say-as" || name === "sayas") {
|
|
1210
1281
|
const interpretAs = attr(token, "interpret-as");
|
|
@@ -1264,6 +1335,14 @@ function validateElement(token, source, diagnostics, voiceName, options) {
|
|
|
1264
1335
|
addDiagnostic(diagnostics, source, token.start, "<audio src> must use HTTPS.");
|
|
1265
1336
|
if (options.allowedAudioOrigins && !options.allowedAudioOrigins.includes(parsed.origin))
|
|
1266
1337
|
addDiagnostic(diagnostics, source, token.start, `<audio src> origin "${parsed.origin}" is not allowed.`);
|
|
1338
|
+
else if (!options.allowExternalAudio)
|
|
1339
|
+
addDiagnostic(
|
|
1340
|
+
diagnostics,
|
|
1341
|
+
source,
|
|
1342
|
+
token.start,
|
|
1343
|
+
`<audio src> external origin "${parsed.origin}" is blocked by default; set allowExternalAudio to true or provide allowedAudioOrigins.`,
|
|
1344
|
+
"error"
|
|
1345
|
+
);
|
|
1267
1346
|
}
|
|
1268
1347
|
}
|
|
1269
1348
|
}
|
|
@@ -1294,7 +1373,24 @@ function validateAzureSsml(ssml, options = {}) {
|
|
|
1294
1373
|
"Azure SSML requires at least one <voice> element under <speak>."
|
|
1295
1374
|
);
|
|
1296
1375
|
const voiceName = voices[0] ? attr(voices[0], "name") : void 0;
|
|
1297
|
-
|
|
1376
|
+
const voiceStyleMap = normalizeVoiceStyleMap(options.customVoiceStyleMap);
|
|
1377
|
+
const policySeverity = diagnosticSeverity(options.unknownVoicePolicy ?? "warn");
|
|
1378
|
+
const voicesToValidate = options.validateNestedVoices === false ? voices.slice(0, 1) : voices;
|
|
1379
|
+
for (const token of voicesToValidate) {
|
|
1380
|
+
const name = attr(token, "name")?.trim();
|
|
1381
|
+
if (name && !voiceStyleMap.has(name.toLowerCase()) && policySeverity)
|
|
1382
|
+
addDiagnostic(
|
|
1383
|
+
diagnostics,
|
|
1384
|
+
ssml,
|
|
1385
|
+
token.start,
|
|
1386
|
+
`Unknown voice "${name}" is not registered in the voice style map.`,
|
|
1387
|
+
policySeverity
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
for (const token of tokens) {
|
|
1391
|
+
const tokenVoiceName = options.validateNestedVoices === false ? voiceName : token.parentVoiceName;
|
|
1392
|
+
validateElement(token, ssml, diagnostics, tokenVoiceName, options, voiceStyleMap);
|
|
1393
|
+
}
|
|
1298
1394
|
return diagnostics;
|
|
1299
1395
|
}
|
|
1300
1396
|
|
|
@@ -1307,4 +1403,4 @@ export {
|
|
|
1307
1403
|
mapSsmlTextNodes,
|
|
1308
1404
|
validateAzureSsml
|
|
1309
1405
|
};
|
|
1310
|
-
//# sourceMappingURL=chunk-
|
|
1406
|
+
//# sourceMappingURL=chunk-4RHHW33A.mjs.map
|