@ssml-builder-js/ssml-editor-react 2.10.0 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/index.js +300 -11
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +300 -11
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/components/VisualSsmlEditor.tsx +324 -2
- 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 +140 -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.12.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.12.0"
|
|
56
56
|
}
|
|
57
57
|
}
|
|
@@ -60,6 +60,222 @@ 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: string,
|
|
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
|
+
interface VisualElementField {
|
|
108
|
+
key: string;
|
|
109
|
+
label: string;
|
|
110
|
+
multiline?: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const VISUAL_ELEMENT_FIELDS: Readonly<Record<string, readonly VisualElementField[]>> = {
|
|
114
|
+
voice: [
|
|
115
|
+
{ key: "name", label: "Voice name" },
|
|
116
|
+
{ key: "effect", label: "Effect" },
|
|
117
|
+
],
|
|
118
|
+
prosody: [
|
|
119
|
+
{ key: "rate", label: "Rate" },
|
|
120
|
+
{ key: "pitch", label: "Pitch" },
|
|
121
|
+
{ key: "volume", label: "Volume" },
|
|
122
|
+
{ key: "contour", label: "Contour" },
|
|
123
|
+
{ key: "range", label: "Range" },
|
|
124
|
+
],
|
|
125
|
+
"express-as": [
|
|
126
|
+
{ key: "style", label: "Style" },
|
|
127
|
+
{ key: "styleDegree", label: "Style degree" },
|
|
128
|
+
{ key: "role", label: "Role" },
|
|
129
|
+
],
|
|
130
|
+
expressAs: [
|
|
131
|
+
{ key: "style", label: "Style" },
|
|
132
|
+
{ key: "styleDegree", label: "Style degree" },
|
|
133
|
+
{ key: "role", label: "Role" },
|
|
134
|
+
],
|
|
135
|
+
"mstts:express-as": [
|
|
136
|
+
{ key: "style", label: "Style" },
|
|
137
|
+
{ key: "styleDegree", label: "Style degree" },
|
|
138
|
+
{ key: "role", label: "Role" },
|
|
139
|
+
],
|
|
140
|
+
"say-as": [
|
|
141
|
+
{ key: "interpretAs", label: "Interpret as" },
|
|
142
|
+
{ key: "format", label: "Format" },
|
|
143
|
+
{ key: "detail", label: "Detail" },
|
|
144
|
+
],
|
|
145
|
+
sayAs: [
|
|
146
|
+
{ key: "interpretAs", label: "Interpret as" },
|
|
147
|
+
{ key: "format", label: "Format" },
|
|
148
|
+
{ key: "detail", label: "Detail" },
|
|
149
|
+
],
|
|
150
|
+
phoneme: [
|
|
151
|
+
{ key: "alphabet", label: "Alphabet" },
|
|
152
|
+
{ key: "ph", label: "Pronunciation" },
|
|
153
|
+
],
|
|
154
|
+
emphasis: [{ key: "level", label: "Level" }],
|
|
155
|
+
audio: [
|
|
156
|
+
{ key: "src", label: "Source URL" },
|
|
157
|
+
{ key: "desc", label: "Description" },
|
|
158
|
+
{ key: "clipBegin", label: "Clip begin" },
|
|
159
|
+
{ key: "clipEnd", label: "Clip end" },
|
|
160
|
+
{ key: "speed", label: "Speed" },
|
|
161
|
+
{ key: "repeatCount", label: "Repeat count" },
|
|
162
|
+
{ key: "repeatDuration", label: "Repeat duration" },
|
|
163
|
+
{ key: "soundLevel", label: "Sound level" },
|
|
164
|
+
],
|
|
165
|
+
mark: [{ key: "name", label: "Mark name" }],
|
|
166
|
+
bookmark: [{ key: "mark", label: "Bookmark name" }],
|
|
167
|
+
sub: [{ key: "alias", label: "Alias" }],
|
|
168
|
+
lang: [{ key: "lang", label: "Language" }],
|
|
169
|
+
lexicon: [{ key: "uri", label: "Lexicon URI" }],
|
|
170
|
+
"mstts:silence": [
|
|
171
|
+
{ key: "typeValue", label: "Silence type" },
|
|
172
|
+
{ key: "value", label: "Value" },
|
|
173
|
+
],
|
|
174
|
+
silence: [
|
|
175
|
+
{ key: "typeValue", label: "Silence type" },
|
|
176
|
+
{ key: "value", label: "Value" },
|
|
177
|
+
],
|
|
178
|
+
"mstts:audioduration": [{ key: "value", label: "Duration" }],
|
|
179
|
+
"mstts:ttsembedding": [{ key: "speakerProfileId", label: "Speaker profile ID" }],
|
|
180
|
+
"mstts:embedding": [
|
|
181
|
+
{ key: "id", label: "Embedding ID" },
|
|
182
|
+
{ key: "speakerProfileId", label: "Speaker profile ID" },
|
|
183
|
+
],
|
|
184
|
+
"mstts:voiceconversion": [
|
|
185
|
+
{ key: "url", label: "Source URL" },
|
|
186
|
+
{ key: "profile", label: "Profile" },
|
|
187
|
+
{ key: "speakerProfileId", label: "Speaker profile ID" },
|
|
188
|
+
],
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
function getElementFields(element: SsmlElement): readonly VisualElementField[] {
|
|
192
|
+
return VISUAL_ELEMENT_FIELDS[elementLabel(element)] ?? [];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function VisualElementInspector({
|
|
196
|
+
document,
|
|
197
|
+
element,
|
|
198
|
+
path,
|
|
199
|
+
readOnly,
|
|
200
|
+
commit,
|
|
201
|
+
}: {
|
|
202
|
+
document: SsmlDocument;
|
|
203
|
+
element: SsmlElement;
|
|
204
|
+
path: number[];
|
|
205
|
+
readOnly: boolean;
|
|
206
|
+
commit: (document: SsmlDocument) => void;
|
|
207
|
+
}): ReactElement {
|
|
208
|
+
const fields = getElementFields(element);
|
|
209
|
+
return (
|
|
210
|
+
<fieldset>
|
|
211
|
+
<legend>{`<${elementLabel(element)}>`}</legend>
|
|
212
|
+
{fields.length === 0 ? (
|
|
213
|
+
<p>This element is preserved in the visual tree. Edit its attributes in Code mode.</p>
|
|
214
|
+
) : (
|
|
215
|
+
fields.map((field) => {
|
|
216
|
+
const value = (element as SsmlElement & Record<string, unknown>)[field.key];
|
|
217
|
+
const inputValue = value === undefined ? "" : String(value);
|
|
218
|
+
const inputId = `ssml-visual-${field.key}`;
|
|
219
|
+
return (
|
|
220
|
+
<label key={field.key} htmlFor={inputId}>
|
|
221
|
+
{field.label}
|
|
222
|
+
{field.multiline ? (
|
|
223
|
+
<textarea
|
|
224
|
+
id={inputId}
|
|
225
|
+
value={inputValue}
|
|
226
|
+
readOnly={readOnly}
|
|
227
|
+
onChange={(event) =>
|
|
228
|
+
commit(updateOptionalElementProperty(document, path, field.key, event.target.value))
|
|
229
|
+
}
|
|
230
|
+
/>
|
|
231
|
+
) : (
|
|
232
|
+
<input
|
|
233
|
+
id={inputId}
|
|
234
|
+
value={inputValue}
|
|
235
|
+
readOnly={readOnly}
|
|
236
|
+
onChange={(event) =>
|
|
237
|
+
commit(updateOptionalElementProperty(document, path, field.key, event.target.value))
|
|
238
|
+
}
|
|
239
|
+
/>
|
|
240
|
+
)}
|
|
241
|
+
</label>
|
|
242
|
+
);
|
|
243
|
+
})
|
|
244
|
+
)}
|
|
245
|
+
</fieldset>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function getElementAncestors(nodes: SsmlNode[], path: number[], ancestors: SsmlElement[] = []): SsmlElement[] {
|
|
250
|
+
const [index, ...rest] = path;
|
|
251
|
+
const node = nodes[index];
|
|
252
|
+
if (!node || !isElement(node)) return ancestors;
|
|
253
|
+
if (rest.length === 0) return ancestors;
|
|
254
|
+
return getElementAncestors(node.children ?? [], rest, [...ancestors, node]);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function buildVisualPreview(document: SsmlDocument, leaf: TextLeaf, selection: { start: number; end: number }): string {
|
|
258
|
+
const start = Math.max(0, Math.min(selection.start, leaf.value.length));
|
|
259
|
+
const end = Math.max(start, Math.min(selection.end, leaf.value.length));
|
|
260
|
+
const value = start === end ? leaf.value : leaf.value.slice(start, end);
|
|
261
|
+
const ancestors = getElementAncestors(document.children ?? [], leaf.path);
|
|
262
|
+
const children = ancestors.reduceRight<SsmlNode[]>(
|
|
263
|
+
(current, ancestor) => [{ ...ancestor, children: current }],
|
|
264
|
+
[value],
|
|
265
|
+
);
|
|
266
|
+
return buildSsml({ ...document, children });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function addDialogTurn(document: SsmlDocument, path: number[]): SsmlDocument {
|
|
270
|
+
return updateElementAtPath(document, path, (element) => ({
|
|
271
|
+
...element,
|
|
272
|
+
children: [
|
|
273
|
+
...(element.children ?? []),
|
|
274
|
+
{ type: "mstts:turn", voice: "en-US-JennyNeural", children: ["New dialog turn"] },
|
|
275
|
+
],
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
|
|
63
279
|
function wrapTextAtPath(
|
|
64
280
|
document: SsmlDocument,
|
|
65
281
|
path: number[],
|
|
@@ -135,6 +351,7 @@ export function VisualSsmlEditor({
|
|
|
135
351
|
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
|
136
352
|
const textLeaves = useMemo(() => collectTextLeaves(document.children ?? []), [document]);
|
|
137
353
|
const selectedLeaf = textLeaves.find((leaf) => leaf.path.join(".") === selectedPath?.join(".")) ?? textLeaves[0];
|
|
354
|
+
const selectedElement = selectedPath ? getNodeAtPath(document.children ?? [], selectedPath) : undefined;
|
|
138
355
|
const diagnostics = useMemo(() => validateAzureSsml(buildSsml(document)), [document]);
|
|
139
356
|
const commit = (nextDocument: SsmlDocument): void => onChange?.(nextDocument);
|
|
140
357
|
|
|
@@ -182,7 +399,112 @@ export function VisualSsmlEditor({
|
|
|
182
399
|
</ul>
|
|
183
400
|
</nav>
|
|
184
401
|
<div className="ssml-editor-visual-form">
|
|
185
|
-
{
|
|
402
|
+
{selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:dialog" ? (
|
|
403
|
+
<fieldset>
|
|
404
|
+
<legend>Dialog</legend>
|
|
405
|
+
<p>Add and edit speaker turns in this dialog.</p>
|
|
406
|
+
<button
|
|
407
|
+
type="button"
|
|
408
|
+
disabled={readOnly}
|
|
409
|
+
onClick={() => commit(addDialogTurn(document, selectedPath ?? []))}
|
|
410
|
+
>
|
|
411
|
+
Add turn
|
|
412
|
+
</button>
|
|
413
|
+
</fieldset>
|
|
414
|
+
) : selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:turn" ? (
|
|
415
|
+
<fieldset>
|
|
416
|
+
<legend>Dialog turn</legend>
|
|
417
|
+
<label>
|
|
418
|
+
Voice
|
|
419
|
+
<input
|
|
420
|
+
value={selectedElement.voice ?? ""}
|
|
421
|
+
readOnly={readOnly}
|
|
422
|
+
onChange={(event) =>
|
|
423
|
+
commit(updateOptionalElementProperty(document, selectedPath ?? [], "voice", event.target.value))
|
|
424
|
+
}
|
|
425
|
+
/>
|
|
426
|
+
</label>
|
|
427
|
+
<label>
|
|
428
|
+
Speaker
|
|
429
|
+
<input
|
|
430
|
+
value={selectedElement.speaker ?? ""}
|
|
431
|
+
readOnly={readOnly}
|
|
432
|
+
onChange={(event) =>
|
|
433
|
+
commit(updateOptionalElementProperty(document, selectedPath ?? [], "speaker", event.target.value))
|
|
434
|
+
}
|
|
435
|
+
/>
|
|
436
|
+
</label>
|
|
437
|
+
<label>
|
|
438
|
+
Turn text
|
|
439
|
+
<textarea
|
|
440
|
+
value={
|
|
441
|
+
selectedElement.children?.filter((child): child is string => typeof child === "string").join("") ??
|
|
442
|
+
""
|
|
443
|
+
}
|
|
444
|
+
readOnly={readOnly}
|
|
445
|
+
onChange={(event) =>
|
|
446
|
+
commit(
|
|
447
|
+
updateElementAtPath(document, selectedPath ?? [], (element) =>
|
|
448
|
+
updateElementText(element, event.target.value),
|
|
449
|
+
),
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
/>
|
|
453
|
+
</label>
|
|
454
|
+
</fieldset>
|
|
455
|
+
) : selectedElement && isElement(selectedElement) && selectedElement.type === "mstts:backgroundaudio" ? (
|
|
456
|
+
<fieldset>
|
|
457
|
+
<legend>Background audio</legend>
|
|
458
|
+
<label>
|
|
459
|
+
Source URL
|
|
460
|
+
<input
|
|
461
|
+
value={selectedElement.src ?? ""}
|
|
462
|
+
readOnly={readOnly}
|
|
463
|
+
onChange={(event) =>
|
|
464
|
+
commit(updateOptionalElementProperty(document, selectedPath ?? [], "src", event.target.value))
|
|
465
|
+
}
|
|
466
|
+
/>
|
|
467
|
+
</label>
|
|
468
|
+
<label>
|
|
469
|
+
Volume
|
|
470
|
+
<input
|
|
471
|
+
value={selectedElement.volume === undefined ? "" : String(selectedElement.volume)}
|
|
472
|
+
readOnly={readOnly}
|
|
473
|
+
onChange={(event) =>
|
|
474
|
+
commit(updateOptionalElementProperty(document, selectedPath ?? [], "volume", event.target.value))
|
|
475
|
+
}
|
|
476
|
+
/>
|
|
477
|
+
</label>
|
|
478
|
+
<label>
|
|
479
|
+
Fade in
|
|
480
|
+
<input
|
|
481
|
+
value={selectedElement.fadeIn === undefined ? "" : String(selectedElement.fadeIn)}
|
|
482
|
+
readOnly={readOnly}
|
|
483
|
+
onChange={(event) =>
|
|
484
|
+
commit(updateOptionalElementProperty(document, selectedPath ?? [], "fadeIn", event.target.value))
|
|
485
|
+
}
|
|
486
|
+
/>
|
|
487
|
+
</label>
|
|
488
|
+
<label>
|
|
489
|
+
Fade out
|
|
490
|
+
<input
|
|
491
|
+
value={selectedElement.fadeOut === undefined ? "" : String(selectedElement.fadeOut)}
|
|
492
|
+
readOnly={readOnly}
|
|
493
|
+
onChange={(event) =>
|
|
494
|
+
commit(updateOptionalElementProperty(document, selectedPath ?? [], "fadeOut", event.target.value))
|
|
495
|
+
}
|
|
496
|
+
/>
|
|
497
|
+
</label>
|
|
498
|
+
</fieldset>
|
|
499
|
+
) : selectedElement && isElement(selectedElement) ? (
|
|
500
|
+
<VisualElementInspector
|
|
501
|
+
document={document}
|
|
502
|
+
element={selectedElement}
|
|
503
|
+
path={selectedPath ?? []}
|
|
504
|
+
readOnly={readOnly}
|
|
505
|
+
commit={commit}
|
|
506
|
+
/>
|
|
507
|
+
) : selectedLeaf ? (
|
|
186
508
|
<>
|
|
187
509
|
<label>
|
|
188
510
|
Text
|
|
@@ -223,7 +545,7 @@ export function VisualSsmlEditor({
|
|
|
223
545
|
{onPreviewSelection && (
|
|
224
546
|
<button
|
|
225
547
|
type="button"
|
|
226
|
-
onClick={() => onPreviewSelection(
|
|
548
|
+
onClick={() => onPreviewSelection(buildVisualPreview(document, selectedLeaf, selection))}
|
|
227
549
|
>
|
|
228
550
|
Preview selection
|
|
229
551
|
</button>
|
|
@@ -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,146 @@ 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("provides forms for every supported Azure element", 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: "voice", name: "en-US-JennyNeural", children: ["Hello"] },
|
|
634
|
+
{ type: "prosody", rate: "slow", children: ["Hello"] },
|
|
635
|
+
{ type: "say-as", interpretAs: "cardinal", children: ["1"] },
|
|
636
|
+
{ type: "phoneme", alphabet: "ipa", ph: "həˈloʊ", children: ["Hello"] },
|
|
637
|
+
{ type: "audio", src: "https://allowed.test/a.mp3" },
|
|
638
|
+
{ type: "mark", name: "chapter-1" },
|
|
639
|
+
{ type: "bookmark", mark: "chapter-1" },
|
|
640
|
+
{ type: "mstts:silence", typeValue: "Comma", value: "100ms" },
|
|
641
|
+
{ type: "mstts:audioduration", value: "10s" },
|
|
642
|
+
{ type: "mstts:embedding", id: "speaker-1" },
|
|
643
|
+
{ type: "mstts:voiceconversion", url: "https://allowed.test/profile" },
|
|
644
|
+
],
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
const tree = screen.getByRole("navigation", { name: "SSML structure tree" });
|
|
649
|
+
const expectedFields = [
|
|
650
|
+
["<voice>", "Voice name"],
|
|
651
|
+
["<prosody>", "Rate"],
|
|
652
|
+
["<say-as>", "Interpret as"],
|
|
653
|
+
["<phoneme>", "Alphabet"],
|
|
654
|
+
["<audio>", "Source URL"],
|
|
655
|
+
["<mark>", "Mark name"],
|
|
656
|
+
["<bookmark>", "Bookmark name"],
|
|
657
|
+
["<mstts:silence>", "Silence type"],
|
|
658
|
+
["<mstts:audioduration>", "Duration"],
|
|
659
|
+
["<mstts:embedding>", "Embedding ID"],
|
|
660
|
+
["<mstts:voiceconversion>", "Source URL"],
|
|
661
|
+
];
|
|
662
|
+
|
|
663
|
+
for (const [element, field] of expectedFields) {
|
|
664
|
+
await user.click(within(tree).getByRole("button", { name: element }));
|
|
665
|
+
expect(screen.getByLabelText(field)).toBeTruthy();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
await user.click(within(tree).getByRole("button", { name: "<mstts:embedding>" }));
|
|
669
|
+
await user.clear(screen.getByLabelText("Embedding ID"));
|
|
670
|
+
await user.type(screen.getByLabelText("Embedding ID"), "speaker-2");
|
|
671
|
+
expect(onChange).toHaveBeenLastCalledWith(
|
|
672
|
+
expect.objectContaining({
|
|
673
|
+
children: expect.arrayContaining([expect.objectContaining({ type: "mstts:embedding", id: "speaker-2" })]),
|
|
674
|
+
}),
|
|
675
|
+
);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("retains voice and prosody context for visual selection preview", async () => {
|
|
679
|
+
const user = userEvent.setup();
|
|
680
|
+
const onPreviewSelection = vi.fn();
|
|
681
|
+
renderEditor({
|
|
682
|
+
editMode: "visual",
|
|
683
|
+
onPreviewSelection,
|
|
684
|
+
document: {
|
|
685
|
+
...editorDocument,
|
|
686
|
+
children: [
|
|
687
|
+
{
|
|
688
|
+
type: "voice",
|
|
689
|
+
name: "en-US-JennyNeural",
|
|
690
|
+
children: [{ type: "prosody", rate: "slow", children: ["Hello world"] }],
|
|
691
|
+
},
|
|
692
|
+
],
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
await user.click(screen.getByRole("button", { name: "Preview selection" }));
|
|
697
|
+
expect(onPreviewSelection).toHaveBeenCalledWith(
|
|
698
|
+
'<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="en-US"><voice name="en-US-JennyNeural"><prosody rate="slow">Hello world</prosody></voice></speak>',
|
|
699
|
+
);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it("edits Azure background audio settings in the visual editor", async () => {
|
|
703
|
+
const user = userEvent.setup();
|
|
704
|
+
const onChange = vi.fn();
|
|
705
|
+
renderEditor({
|
|
706
|
+
editMode: "visual",
|
|
707
|
+
onChange,
|
|
708
|
+
document: {
|
|
709
|
+
...editorDocument,
|
|
710
|
+
children: [
|
|
711
|
+
{ type: "mstts:backgroundaudio", src: "https://allowed.test/music.mp3", volume: "50", fadeIn: "500" },
|
|
712
|
+
{ type: "voice", name: "en-US-JennyNeural", children: ["Hello"] },
|
|
713
|
+
],
|
|
714
|
+
},
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
await user.click(screen.getByRole("button", { name: "<mstts:backgroundaudio>" }));
|
|
718
|
+
await user.clear(screen.getByRole("textbox", { name: "Volume" }));
|
|
719
|
+
await user.type(screen.getByRole("textbox", { name: "Volume" }), "70");
|
|
720
|
+
expect(onChange).toHaveBeenLastCalledWith(
|
|
721
|
+
expect.objectContaining({
|
|
722
|
+
children: expect.arrayContaining([expect.objectContaining({ type: "mstts:backgroundaudio", volume: "70" })]),
|
|
723
|
+
}),
|
|
724
|
+
);
|
|
725
|
+
});
|
|
726
|
+
|
|
587
727
|
it("updates toolbar and popover text when the locale changes", async () => {
|
|
588
728
|
const user = userEvent.setup();
|
|
589
729
|
const { rerender } = renderEditor({ locale: "ja", showToolbarLabels: true });
|