@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssml-builder-js/ssml-editor-react",
3
- "version": "2.11.0",
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.11.0"
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: "voice" | "speaker" | "src" | "volume" | "fadeIn" | "fadeOut",
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(buildSsml({ ...document, children: [selectedLeaf.value] }))}
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();