@ssml-builder-js/ssml-editor-react 2.9.0 → 2.11.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/CHANGELOG.md +22 -0
- package/dist/index.js +155 -10
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +155 -10
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/components/VisualSsmlEditor.tsx +153 -1
- package/src/constants/azureVoiceStyleMap.generated.ts +3 -0
- package/src/constants/ssmlPresets.ts +3 -2
- package/src/ssmlCompletion.ts +1 -1
- package/src/ssmlHover.ts +6 -6
- package/test/ui-components.test.tsx +62 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssml-builder-js/ssml-editor-react",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"description": "React-based SSML editor component",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -52,6 +52,6 @@
|
|
|
52
52
|
}
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@ssml-builder-js/ssml-core": "^2.
|
|
55
|
+
"@ssml-builder-js/ssml-core": "^2.11.0"
|
|
56
56
|
}
|
|
57
57
|
}
|
|
@@ -60,6 +60,60 @@ function updateTextAtPath(document: SsmlDocument, path: number[], value: string)
|
|
|
60
60
|
return { ...document, children: updateNodesAtPath(document.children ?? [], path, () => value) };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function getNodeAtPath(nodes: SsmlNode[], path: number[]): SsmlNode | undefined {
|
|
64
|
+
let current: SsmlNode | undefined;
|
|
65
|
+
let currentNodes = nodes;
|
|
66
|
+
for (const index of path) {
|
|
67
|
+
current = currentNodes[index];
|
|
68
|
+
if (current === undefined) return undefined;
|
|
69
|
+
if (!isElement(current)) {
|
|
70
|
+
currentNodes = [];
|
|
71
|
+
} else {
|
|
72
|
+
currentNodes = current.children ?? [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return current;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function updateElementAtPath(
|
|
79
|
+
document: SsmlDocument,
|
|
80
|
+
path: number[],
|
|
81
|
+
update: (element: SsmlElement) => SsmlElement,
|
|
82
|
+
): SsmlDocument {
|
|
83
|
+
return {
|
|
84
|
+
...document,
|
|
85
|
+
children: updateNodesAtPath(document.children ?? [], path, (node) => (isElement(node) ? update(node) : node)),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function updateElementText(element: SsmlElement, value: string): SsmlElement {
|
|
90
|
+
return { ...element, children: [value] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function updateOptionalElementProperty(
|
|
94
|
+
document: SsmlDocument,
|
|
95
|
+
path: number[],
|
|
96
|
+
property: "voice" | "speaker" | "src" | "volume" | "fadeIn" | "fadeOut",
|
|
97
|
+
value: string,
|
|
98
|
+
): SsmlDocument {
|
|
99
|
+
return updateElementAtPath(document, path, (element) => {
|
|
100
|
+
const next = { ...element } as SsmlElement & Record<string, unknown>;
|
|
101
|
+
if (value.trim()) next[property] = value;
|
|
102
|
+
else delete next[property];
|
|
103
|
+
return next;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function addDialogTurn(document: SsmlDocument, path: number[]): SsmlDocument {
|
|
108
|
+
return updateElementAtPath(document, path, (element) => ({
|
|
109
|
+
...element,
|
|
110
|
+
children: [
|
|
111
|
+
...(element.children ?? []),
|
|
112
|
+
{ type: "mstts:turn", voice: "en-US-JennyNeural", children: ["New dialog turn"] },
|
|
113
|
+
],
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
|
|
63
117
|
function wrapTextAtPath(
|
|
64
118
|
document: SsmlDocument,
|
|
65
119
|
path: number[],
|
|
@@ -135,6 +189,7 @@ export function VisualSsmlEditor({
|
|
|
135
189
|
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
|
136
190
|
const textLeaves = useMemo(() => collectTextLeaves(document.children ?? []), [document]);
|
|
137
191
|
const selectedLeaf = textLeaves.find((leaf) => leaf.path.join(".") === selectedPath?.join(".")) ?? textLeaves[0];
|
|
192
|
+
const selectedElement = selectedPath ? getNodeAtPath(document.children ?? [], selectedPath) : undefined;
|
|
138
193
|
const diagnostics = useMemo(() => validateAzureSsml(buildSsml(document)), [document]);
|
|
139
194
|
const commit = (nextDocument: SsmlDocument): void => onChange?.(nextDocument);
|
|
140
195
|
|
|
@@ -182,7 +237,104 @@ export function VisualSsmlEditor({
|
|
|
182
237
|
</ul>
|
|
183
238
|
</nav>
|
|
184
239
|
<div className="ssml-editor-visual-form">
|
|
185
|
-
{
|
|
240
|
+
{selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:dialog" ? (
|
|
241
|
+
<fieldset>
|
|
242
|
+
<legend>Dialog</legend>
|
|
243
|
+
<p>Add and edit speaker turns in this dialog.</p>
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
disabled={readOnly}
|
|
247
|
+
onClick={() => commit(addDialogTurn(document, selectedPath ?? []))}
|
|
248
|
+
>
|
|
249
|
+
Add turn
|
|
250
|
+
</button>
|
|
251
|
+
</fieldset>
|
|
252
|
+
) : selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:turn" ? (
|
|
253
|
+
<fieldset>
|
|
254
|
+
<legend>Dialog turn</legend>
|
|
255
|
+
<label>
|
|
256
|
+
Voice
|
|
257
|
+
<input
|
|
258
|
+
value={selectedElement.voice ?? ""}
|
|
259
|
+
readOnly={readOnly}
|
|
260
|
+
onChange={(event) =>
|
|
261
|
+
commit(updateOptionalElementProperty(document, selectedPath ?? [], "voice", event.target.value))
|
|
262
|
+
}
|
|
263
|
+
/>
|
|
264
|
+
</label>
|
|
265
|
+
<label>
|
|
266
|
+
Speaker
|
|
267
|
+
<input
|
|
268
|
+
value={selectedElement.speaker ?? ""}
|
|
269
|
+
readOnly={readOnly}
|
|
270
|
+
onChange={(event) =>
|
|
271
|
+
commit(updateOptionalElementProperty(document, selectedPath ?? [], "speaker", event.target.value))
|
|
272
|
+
}
|
|
273
|
+
/>
|
|
274
|
+
</label>
|
|
275
|
+
<label>
|
|
276
|
+
Turn text
|
|
277
|
+
<textarea
|
|
278
|
+
value={
|
|
279
|
+
selectedElement.children?.filter((child): child is string => typeof child === "string").join("") ??
|
|
280
|
+
""
|
|
281
|
+
}
|
|
282
|
+
readOnly={readOnly}
|
|
283
|
+
onChange={(event) =>
|
|
284
|
+
commit(
|
|
285
|
+
updateElementAtPath(document, selectedPath ?? [], (element) =>
|
|
286
|
+
updateElementText(element, event.target.value),
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
/>
|
|
291
|
+
</label>
|
|
292
|
+
</fieldset>
|
|
293
|
+
) : selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:backgroundaudio" ? (
|
|
294
|
+
<fieldset>
|
|
295
|
+
<legend>Background audio</legend>
|
|
296
|
+
<label>
|
|
297
|
+
Source URL
|
|
298
|
+
<input
|
|
299
|
+
value={selectedElement.src ?? ""}
|
|
300
|
+
readOnly={readOnly}
|
|
301
|
+
onChange={(event) =>
|
|
302
|
+
commit(updateOptionalElementProperty(document, selectedPath ?? [], "src", event.target.value))
|
|
303
|
+
}
|
|
304
|
+
/>
|
|
305
|
+
</label>
|
|
306
|
+
<label>
|
|
307
|
+
Volume
|
|
308
|
+
<input
|
|
309
|
+
value={selectedElement.volume === undefined ? "" : String(selectedElement.volume)}
|
|
310
|
+
readOnly={readOnly}
|
|
311
|
+
onChange={(event) =>
|
|
312
|
+
commit(updateOptionalElementProperty(document, selectedPath ?? [], "volume", event.target.value))
|
|
313
|
+
}
|
|
314
|
+
/>
|
|
315
|
+
</label>
|
|
316
|
+
<label>
|
|
317
|
+
Fade in
|
|
318
|
+
<input
|
|
319
|
+
value={selectedElement.fadeIn === undefined ? "" : String(selectedElement.fadeIn)}
|
|
320
|
+
readOnly={readOnly}
|
|
321
|
+
onChange={(event) =>
|
|
322
|
+
commit(updateOptionalElementProperty(document, selectedPath ?? [], "fadeIn", event.target.value))
|
|
323
|
+
}
|
|
324
|
+
/>
|
|
325
|
+
</label>
|
|
326
|
+
<label>
|
|
327
|
+
Fade out
|
|
328
|
+
<input
|
|
329
|
+
value={selectedElement.fadeOut === undefined ? "" : String(selectedElement.fadeOut)}
|
|
330
|
+
readOnly={readOnly}
|
|
331
|
+
onChange={(event) =>
|
|
332
|
+
commit(updateOptionalElementProperty(document, selectedPath ?? [], "fadeOut", event.target.value))
|
|
333
|
+
}
|
|
334
|
+
/>
|
|
335
|
+
</label>
|
|
336
|
+
</fieldset>
|
|
337
|
+
) : selectedLeaf ? (
|
|
186
338
|
<>
|
|
187
339
|
<label>
|
|
188
340
|
Text
|
|
@@ -46,6 +46,9 @@ export const AZURE_VOICE_STYLE_MAP = {
|
|
|
46
46
|
],
|
|
47
47
|
"es-ES-ElviraNeural": [],
|
|
48
48
|
"fil-PH-AngeloNeural": [],
|
|
49
|
+
"fil-PH-Angelo:DragonHDLatestNeural": [],
|
|
50
|
+
"fil-PH-BlessicaNeural": [],
|
|
51
|
+
"fil-PH-Blessica:DragonHDLatestNeural": [],
|
|
49
52
|
"fr-FR-DeniseNeural": ["cheerful", "sad"],
|
|
50
53
|
"fr-FR-HenriNeural": ["cheerful", "sad"],
|
|
51
54
|
"id-ID-GadisNeural": [],
|
|
@@ -262,6 +262,7 @@ export const SAY_AS_PRESETS = [
|
|
|
262
262
|
export const LANGUAGE_PRESETS = ["ja-JP", "en-US", "de-DE", "fr-FR"] as const;
|
|
263
263
|
export const SILENCE_VALUE_PRESETS = ["300ms", "500ms", "1s"] as const;
|
|
264
264
|
export const AUDIO_DURATION_PRESETS = ["5s", "10s", "30s"] as const;
|
|
265
|
+
export const BACKGROUND_AUDIO_DURATION_PRESETS = ["0", "500", "1000", "3000", "10000"] as const;
|
|
265
266
|
export const SILENCE_TYPE_PRESETS = [
|
|
266
267
|
"Leading",
|
|
267
268
|
"Tailing",
|
|
@@ -324,8 +325,8 @@ export const SSML_ATTRIBUTE_PRESETS = {
|
|
|
324
325
|
},
|
|
325
326
|
"mstts:backgroundaudio": {
|
|
326
327
|
volume: PROSODY_VOLUME_PRESETS,
|
|
327
|
-
fadein:
|
|
328
|
-
fadeout:
|
|
328
|
+
fadein: BACKGROUND_AUDIO_DURATION_PRESETS,
|
|
329
|
+
fadeout: BACKGROUND_AUDIO_DURATION_PRESETS,
|
|
329
330
|
},
|
|
330
331
|
silence: {
|
|
331
332
|
type: SILENCE_TYPE_PRESETS,
|
package/src/ssmlCompletion.ts
CHANGED
|
@@ -56,7 +56,7 @@ const SSML_COMPLETION_SNIPPETS = [
|
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
58
|
label: "mstts:backgroundaudio",
|
|
59
|
-
insertText: `<mstts:backgroundaudio src="\${1:https://example.com/audio.mp3}" volume="\${2
|
|
59
|
+
insertText: `<mstts:backgroundaudio src="\${1:https://example.com/audio.mp3}" volume="\${2:70}" fadein="\${3:1000}" fadeout="\${4:1000}" />`,
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
62
|
label: "mstts:ttsembedding",
|
package/src/ssmlHover.ts
CHANGED
|
@@ -366,18 +366,18 @@ const SSML_TAG_DEFINITIONS: readonly SsmlTagDefinition[] = [
|
|
|
366
366
|
},
|
|
367
367
|
{
|
|
368
368
|
name: "volume",
|
|
369
|
-
description: "The background audio volume
|
|
370
|
-
example: "
|
|
369
|
+
description: "The background audio volume from 0 to 100.",
|
|
370
|
+
example: "70",
|
|
371
371
|
},
|
|
372
372
|
{
|
|
373
373
|
name: "fadein",
|
|
374
|
-
description: "The fade-in duration
|
|
375
|
-
example: "
|
|
374
|
+
description: "The fade-in duration in milliseconds from 0 to 10000.",
|
|
375
|
+
example: "1000",
|
|
376
376
|
},
|
|
377
377
|
{
|
|
378
378
|
name: "fadeout",
|
|
379
|
-
description: "The fade-out duration
|
|
380
|
-
example: "
|
|
379
|
+
description: "The fade-out duration in milliseconds from 0 to 10000.",
|
|
380
|
+
example: "1000",
|
|
381
381
|
},
|
|
382
382
|
],
|
|
383
383
|
},
|
|
@@ -584,6 +584,68 @@ describe("SsmlEditor props", () => {
|
|
|
584
584
|
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ version: "1.0" }));
|
|
585
585
|
});
|
|
586
586
|
|
|
587
|
+
it("adds and edits Azure dialog turns in the visual editor", async () => {
|
|
588
|
+
const user = userEvent.setup();
|
|
589
|
+
const onChange = vi.fn();
|
|
590
|
+
renderEditor({
|
|
591
|
+
editMode: "visual",
|
|
592
|
+
onChange,
|
|
593
|
+
document: {
|
|
594
|
+
...editorDocument,
|
|
595
|
+
children: [
|
|
596
|
+
{
|
|
597
|
+
type: "voice",
|
|
598
|
+
name: "en-US-MultiTalker-Ava-Andrew:DragonHDLatestNeural",
|
|
599
|
+
children: [
|
|
600
|
+
{ type: "mstts:dialog", children: [{ type: "mstts:turn", speaker: "ava", children: ["Hello"] }] },
|
|
601
|
+
],
|
|
602
|
+
},
|
|
603
|
+
],
|
|
604
|
+
},
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
await user.click(screen.getByRole("button", { name: "<mstts:dialog>" }));
|
|
608
|
+
await user.click(screen.getByRole("button", { name: "Add turn" }));
|
|
609
|
+
expect(onChange).toHaveBeenLastCalledWith(
|
|
610
|
+
expect.objectContaining({
|
|
611
|
+
children: [
|
|
612
|
+
expect.objectContaining({
|
|
613
|
+
children: [
|
|
614
|
+
expect.objectContaining({
|
|
615
|
+
children: expect.arrayContaining([expect.objectContaining({ type: "mstts:turn" })]),
|
|
616
|
+
}),
|
|
617
|
+
],
|
|
618
|
+
}),
|
|
619
|
+
],
|
|
620
|
+
}),
|
|
621
|
+
);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("edits Azure background audio settings in the visual editor", async () => {
|
|
625
|
+
const user = userEvent.setup();
|
|
626
|
+
const onChange = vi.fn();
|
|
627
|
+
renderEditor({
|
|
628
|
+
editMode: "visual",
|
|
629
|
+
onChange,
|
|
630
|
+
document: {
|
|
631
|
+
...editorDocument,
|
|
632
|
+
children: [
|
|
633
|
+
{ type: "mstts:backgroundaudio", src: "https://allowed.test/music.mp3", volume: "50", fadeIn: "500" },
|
|
634
|
+
{ type: "voice", name: "en-US-JennyNeural", children: ["Hello"] },
|
|
635
|
+
],
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
await user.click(screen.getByRole("button", { name: "<mstts:backgroundaudio>" }));
|
|
640
|
+
await user.clear(screen.getByRole("textbox", { name: "Volume" }));
|
|
641
|
+
await user.type(screen.getByRole("textbox", { name: "Volume" }), "70");
|
|
642
|
+
expect(onChange).toHaveBeenLastCalledWith(
|
|
643
|
+
expect.objectContaining({
|
|
644
|
+
children: expect.arrayContaining([expect.objectContaining({ type: "mstts:backgroundaudio", volume: "70" })]),
|
|
645
|
+
}),
|
|
646
|
+
);
|
|
647
|
+
});
|
|
648
|
+
|
|
587
649
|
it("updates toolbar and popover text when the locale changes", async () => {
|
|
588
650
|
const user = userEvent.setup();
|
|
589
651
|
const { rerender } = renderEditor({ locale: "ja", showToolbarLabels: true });
|