@ssml-builder-js/ssml-editor-react 2.11.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 +6 -0
- package/dist/index.js +146 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +146 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/components/VisualSsmlEditor.tsx +172 -2
- package/test/ui-components.test.tsx +78 -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
|
}
|
|
@@ -93,7 +93,7 @@ function updateElementText(element: SsmlElement, value: string): SsmlElement {
|
|
|
93
93
|
function updateOptionalElementProperty(
|
|
94
94
|
document: SsmlDocument,
|
|
95
95
|
path: number[],
|
|
96
|
-
property:
|
|
96
|
+
property: string,
|
|
97
97
|
value: string,
|
|
98
98
|
): SsmlDocument {
|
|
99
99
|
return updateElementAtPath(document, path, (element) => {
|
|
@@ -104,6 +104,168 @@ function updateOptionalElementProperty(
|
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
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
|
+
|
|
107
269
|
function addDialogTurn(document: SsmlDocument, path: number[]): SsmlDocument {
|
|
108
270
|
return updateElementAtPath(document, path, (element) => ({
|
|
109
271
|
...element,
|
|
@@ -334,6 +496,14 @@ export function VisualSsmlEditor({
|
|
|
334
496
|
/>
|
|
335
497
|
</label>
|
|
336
498
|
</fieldset>
|
|
499
|
+
) : selectedElement && isElement(selectedElement) ? (
|
|
500
|
+
<VisualElementInspector
|
|
501
|
+
document={document}
|
|
502
|
+
element={selectedElement}
|
|
503
|
+
path={selectedPath ?? []}
|
|
504
|
+
readOnly={readOnly}
|
|
505
|
+
commit={commit}
|
|
506
|
+
/>
|
|
337
507
|
) : selectedLeaf ? (
|
|
338
508
|
<>
|
|
339
509
|
<label>
|
|
@@ -375,7 +545,7 @@ export function VisualSsmlEditor({
|
|
|
375
545
|
{onPreviewSelection && (
|
|
376
546
|
<button
|
|
377
547
|
type="button"
|
|
378
|
-
onClick={() => onPreviewSelection(
|
|
548
|
+
onClick={() => onPreviewSelection(buildVisualPreview(document, selectedLeaf, selection))}
|
|
379
549
|
>
|
|
380
550
|
Preview selection
|
|
381
551
|
</button>
|
|
@@ -621,6 +621,84 @@ describe("SsmlEditor props", () => {
|
|
|
621
621
|
);
|
|
622
622
|
});
|
|
623
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
|
+
|
|
624
702
|
it("edits Azure background audio settings in the visual editor", async () => {
|
|
625
703
|
const user = userEvent.setup();
|
|
626
704
|
const onChange = vi.fn();
|