@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssml-builder-js/ssml-editor-react",
3
- "version": "2.9.0",
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.9.0"
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
- {selectedLeaf ? (
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: AUDIO_DURATION_PRESETS,
328
- fadeout: AUDIO_DURATION_PRESETS,
328
+ fadein: BACKGROUND_AUDIO_DURATION_PRESETS,
329
+ fadeout: BACKGROUND_AUDIO_DURATION_PRESETS,
329
330
  },
330
331
  silence: {
331
332
  type: SILENCE_TYPE_PRESETS,
@@ -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:-3dB}" />`,
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, for example `-3dB` or `medium`.",
370
- example: "-3dB",
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, for example `1s`.",
375
- example: "1s",
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, for example `500ms`.",
380
- example: "500ms",
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 });