@ssml-builder-js/ssml-editor-react 2.8.0 → 2.9.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.8.0",
3
+ "version": "2.9.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.8.0"
55
+ "@ssml-builder-js/ssml-core": "^2.9.0"
56
56
  }
57
57
  }
@@ -1,4 +1,4 @@
1
- import { Fragment, forwardRef, useImperativeHandle } from "react";
1
+ import { Fragment, forwardRef, useEffect, useImperativeHandle, useState } from "react";
2
2
  import type { CSSProperties, ReactElement, ReactNode } from "react";
3
3
  import Editor from "@monaco-editor/react";
4
4
  import type { SsmlDocument } from "@ssml-builder-js/ssml-core";
@@ -6,6 +6,8 @@ import { isSsmlEditorButtonVisible, type SsmlEditorButton, type SsmlEditorButton
6
6
  import {
7
7
  BREAK_TIME_DESCRIPTIONS,
8
8
  BREAK_TIME_PRESETS,
9
+ AUDIO_DURATION_DESCRIPTIONS,
10
+ AUDIO_DURATION_PRESETS,
9
11
  EMPHASIS_LEVEL_DESCRIPTIONS,
10
12
  EMPHASIS_LEVEL_PRESETS,
11
13
  EXPRESS_AS_STYLE_DESCRIPTIONS,
@@ -38,8 +40,9 @@ import { InsertionPopover } from "./components/popovers/InsertionPopover";
38
40
  import { useSsmlEditorState } from "./hooks/useSsmlEditorState";
39
41
  import { useSsmlMonaco } from "./hooks/useSsmlMonaco";
40
42
  import { editorStyles as styles } from "./styles/editorStyles";
43
+ import { VisualSsmlEditor } from "./components/VisualSsmlEditor";
41
44
  const UNGROUPED_TOOLBAR_GROUP = "__ssml-editor-ungrouped__";
42
- const TIMING_POPOVER_TAGS = new Set(["break", "mstts:silence"]);
45
+ const TIMING_POPOVER_TAGS = new Set(["break", "mstts:silence", "mstts:audioduration"]);
43
46
  const PROSODY_POPOVER_TAGS = new Set(["prosody", "mstts:express-as", "voice", "emphasis"]);
44
47
  const TEXT_POPOVER_TAGS = new Set(["sub", "say-as", "phoneme", "w", "lang"]);
45
48
 
@@ -365,13 +368,34 @@ export const SSML_INSERTIONS = [
365
368
  mode: "insert",
366
369
  }),
367
370
  },
371
+ {
372
+ id: "mstts:audioduration",
373
+ icon: "◷",
374
+ tagName: "mstts:audioduration",
375
+ selfClosing: true,
376
+ labels: { ja: "音声長", en: "Audio duration" },
377
+ descriptions: {
378
+ ja: "合成音声の目標時間を指定します。",
379
+ en: "Sets the target duration of synthesized audio.",
380
+ },
381
+ parameterDescription: {
382
+ ja: "目標時間を選択します。",
383
+ en: "Selects the target duration.",
384
+ },
385
+ options: createInsertionOptions(AUDIO_DURATION_PRESETS, AUDIO_DURATION_DESCRIPTIONS),
386
+ createTemplate: (value) => ({
387
+ prefix: `<mstts:audioduration value="${value}"/>`,
388
+ suffix: "",
389
+ mode: "insert",
390
+ }),
391
+ },
368
392
  ] satisfies readonly SsmlInsertionDefinition[];
369
393
 
370
394
  const DEFAULT_INSERTION_GROUPS = [
371
395
  {
372
396
  id: "pauses",
373
397
  labels: { ja: "間・無音", en: "Pauses" },
374
- insertionIds: ["break", "mstts:silence"],
398
+ insertionIds: ["break", "mstts:silence", "mstts:audioduration"],
375
399
  },
376
400
  {
377
401
  id: "prosody",
@@ -614,6 +638,60 @@ const STYLE_CSS = `
614
638
  > .ssml-editor-help-settings-summary::before {
615
639
  content: "▾";
616
640
  }
641
+ [data-ssml-editor] .ssml-editor-visual-layout {
642
+ display: grid;
643
+ grid-template-columns: minmax(12rem, 0.35fr) minmax(16rem, 1fr);
644
+ gap: 1rem;
645
+ }
646
+ [data-ssml-editor] .ssml-editor-visual-tree,
647
+ [data-ssml-editor] .ssml-editor-visual-form {
648
+ display: grid;
649
+ gap: 0.5rem;
650
+ align-content: start;
651
+ }
652
+ [data-ssml-editor] .ssml-editor-visual-tree ul {
653
+ margin: 0;
654
+ padding-left: 1.25rem;
655
+ }
656
+ [data-ssml-editor] .ssml-editor-visual-tree button,
657
+ [data-ssml-editor] .ssml-editor-visual-breadcrumb button,
658
+ [data-ssml-editor] .ssml-editor-visual-actions button {
659
+ padding: 0.35rem 0.5rem;
660
+ border: 1px solid var(--ssml-editor-control-border);
661
+ border-radius: 0.25rem;
662
+ color: var(--ssml-editor-color);
663
+ background: var(--ssml-editor-control-bg);
664
+ cursor: pointer;
665
+ }
666
+ [data-ssml-editor] .ssml-editor-visual-tree button[aria-current] {
667
+ border-color: var(--ssml-editor-active-border);
668
+ background: var(--ssml-editor-active-bg);
669
+ }
670
+ [data-ssml-editor] .ssml-editor-visual-breadcrumb,
671
+ [data-ssml-editor] .ssml-editor-visual-actions {
672
+ display: flex;
673
+ flex-wrap: wrap;
674
+ gap: 0.4rem;
675
+ align-items: center;
676
+ }
677
+ [data-ssml-editor] .ssml-editor-visual-form textarea {
678
+ box-sizing: border-box;
679
+ width: 100%;
680
+ min-height: 6rem;
681
+ padding: 0.5rem;
682
+ color: var(--ssml-editor-color);
683
+ background: var(--ssml-editor-control-bg);
684
+ border: 1px solid var(--ssml-editor-control-border);
685
+ border-radius: 0.25rem;
686
+ font: inherit;
687
+ }
688
+ [data-ssml-editor] .ssml-editor-visual-errors {
689
+ padding: 0.5rem;
690
+ color: var(--ssml-editor-error);
691
+ background: var(--ssml-editor-error-bg);
692
+ border: 1px solid var(--ssml-editor-error);
693
+ border-radius: 0.25rem;
694
+ }
617
695
  `.trim();
618
696
 
619
697
  function injectEditorTheme(): void {
@@ -660,6 +738,8 @@ export interface SsmlEditorProps {
660
738
  minHeight?: string | number;
661
739
  /** Whether the Monaco editor is read-only. */
662
740
  readOnly?: boolean;
741
+ /** Selects the XML source editor or the structured visual editor. */
742
+ editMode?: SsmlEditorEditMode;
663
743
  /** Monaco theme mode. */
664
744
  theme?: SsmlEditorTheme;
665
745
  /** Monaco editor font size. */
@@ -700,6 +780,8 @@ export interface SsmlEditorProps {
700
780
  displayStyle?: CSSProperties;
701
781
  }
702
782
 
783
+ export type SsmlEditorEditMode = "code" | "visual";
784
+
703
785
  export interface SelectionInfo {
704
786
  selectedText: string;
705
787
  characterCount: number;
@@ -791,9 +873,12 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
791
873
  displayClassName,
792
874
  displayStyle,
793
875
  loadingFallback,
876
+ editMode: editModeProp = "code",
794
877
  }: SsmlEditorProps,
795
878
  ref,
796
879
  ): ReactElement {
880
+ const [editMode, setEditMode] = useState<SsmlEditorEditMode>(editModeProp);
881
+ useEffect(() => setEditMode(editModeProp), [editModeProp]);
797
882
  const resolvedEditorOptions: SsmlEditorOptions = {
798
883
  ...settings,
799
884
  ...editorOptions,
@@ -882,6 +967,7 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
882
967
 
883
968
  const {
884
969
  editorRef,
970
+ draftDocument,
885
971
  text,
886
972
  selectionOverlay,
887
973
  activeTags,
@@ -893,6 +979,7 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
893
979
  syntaxError,
894
980
  setSyntaxError,
895
981
  helpPanelId,
982
+ commit,
896
983
  handleTextChange,
897
984
  handleInsert,
898
985
  handleInsertBreak,
@@ -1187,6 +1274,27 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
1187
1274
  data-ssml-editor-toolbar-actions=""
1188
1275
  >
1189
1276
  {renderToolbarItems()}
1277
+ <span style={styles.toolbarSeparator} aria-hidden="true" />
1278
+ <button
1279
+ type="button"
1280
+ style={
1281
+ editMode === "visual" ? { ...toolbarButtonStyle, ...styles.toolbarButtonActive } : toolbarButtonStyle
1282
+ }
1283
+ aria-pressed={editMode === "visual"}
1284
+ onClick={() => setEditMode("visual")}
1285
+ >
1286
+ Visual
1287
+ </button>
1288
+ <button
1289
+ type="button"
1290
+ style={
1291
+ editMode === "code" ? { ...toolbarButtonStyle, ...styles.toolbarButtonActive } : toolbarButtonStyle
1292
+ }
1293
+ aria-pressed={editMode === "code"}
1294
+ onClick={() => setEditMode("code")}
1295
+ >
1296
+ Code
1297
+ </button>
1190
1298
  </div>
1191
1299
  </div>
1192
1300
  )}
@@ -1232,65 +1340,76 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
1232
1340
  )}
1233
1341
  </section>
1234
1342
  )}
1235
- <div style={{ ...styles.editor, minHeight: editorMinHeight }}>
1236
- <Editor
1237
- height={editorHeight}
1238
- language="xml"
1239
- theme={isDarkTheme ? "vs-dark" : "light"}
1240
- options={{
1241
- automaticLayout: resolvedEditorOptions.automaticLayout ?? true,
1242
- autoClosingBrackets: "never",
1243
- fontSize: resolvedEditorOptions.fontSize,
1244
- hover: { enabled: "on" },
1245
- inlayHints: { enabled: decorationsVisible ? "on" : "off" },
1246
- lineNumbers: resolvedEditorOptions.lineNumbers,
1247
- minimap: {
1248
- enabled: resolvedEditorOptions.minimap ?? true,
1249
- },
1250
- readOnly: isReadOnly,
1251
- wordWrap: resolvedEditorOptions.wordWrap,
1252
- }}
1253
- value={text}
1254
- loading={loadingFallback}
1255
- onMount={onMount}
1256
- onChange={(value) => handleTextChange(value ?? "")}
1257
- />
1258
- {selectionOverlay.hasSelection && selectionOverlay.position && (
1259
- <div
1260
- role="toolbar"
1261
- aria-label={copy.selectionActions}
1262
- data-ssml-editor-selection-actions=""
1263
- style={{
1264
- ...styles.selectionActions,
1265
- top: selectionOverlay.position.top,
1266
- left: selectionOverlay.position.left,
1267
- transform:
1268
- selectionOverlay.placement === "above"
1269
- ? "translateY(calc(-100% - 0.5rem))"
1270
- : `translateY(calc(${selectionOverlay.position.height}px + 0.5rem))`,
1343
+ {editMode === "visual" ? (
1344
+ <div style={styles.visual}>
1345
+ <VisualSsmlEditor
1346
+ document={draftDocument}
1347
+ readOnly={isReadOnly}
1348
+ onChange={commit}
1349
+ onPreviewSelection={onPreviewSelection}
1350
+ />
1351
+ </div>
1352
+ ) : (
1353
+ <div style={{ ...styles.editor, minHeight: editorMinHeight }}>
1354
+ <Editor
1355
+ height={editorHeight}
1356
+ language="xml"
1357
+ theme={isDarkTheme ? "vs-dark" : "light"}
1358
+ options={{
1359
+ automaticLayout: resolvedEditorOptions.automaticLayout ?? true,
1360
+ autoClosingBrackets: "never",
1361
+ fontSize: resolvedEditorOptions.fontSize,
1362
+ hover: { enabled: "on" },
1363
+ inlayHints: { enabled: decorationsVisible ? "on" : "off" },
1364
+ lineNumbers: resolvedEditorOptions.lineNumbers,
1365
+ minimap: {
1366
+ enabled: resolvedEditorOptions.minimap ?? true,
1367
+ },
1368
+ readOnly: isReadOnly,
1369
+ wordWrap: resolvedEditorOptions.wordWrap,
1271
1370
  }}
1272
- onMouseDown={(event) => event.preventDefault()}
1273
- >
1274
- <span style={styles.selectionCount} aria-live="polite">
1275
- {selectionOverlay.characterCount}
1276
- {copy.selectionCountSuffix}
1277
- </span>
1278
- <span style={styles.selectionActionsDivider} aria-hidden="true" />
1279
- <button
1280
- type="button"
1281
- style={styles.selectionActionButton}
1282
- title={copy.previewSelectionTitle}
1283
- disabled={!canPreviewSelection}
1284
- onClick={previewSelection}
1371
+ value={text}
1372
+ loading={loadingFallback}
1373
+ onMount={onMount}
1374
+ onChange={(value) => handleTextChange(value ?? "")}
1375
+ />
1376
+ {selectionOverlay.hasSelection && selectionOverlay.position && (
1377
+ <div
1378
+ role="toolbar"
1379
+ aria-label={copy.selectionActions}
1380
+ data-ssml-editor-selection-actions=""
1381
+ style={{
1382
+ ...styles.selectionActions,
1383
+ top: selectionOverlay.position.top,
1384
+ left: selectionOverlay.position.left,
1385
+ transform:
1386
+ selectionOverlay.placement === "above"
1387
+ ? "translateY(calc(-100% - 0.5rem))"
1388
+ : `translateY(calc(${selectionOverlay.position.height}px + 0.5rem))`,
1389
+ }}
1390
+ onMouseDown={(event) => event.preventDefault()}
1285
1391
  >
1286
- <span style={styles.selectionActionIcon} aria-hidden="true">
1287
-
1392
+ <span style={styles.selectionCount} aria-live="polite">
1393
+ {selectionOverlay.characterCount}
1394
+ {copy.selectionCountSuffix}
1288
1395
  </span>
1289
- {copy.previewSelection}
1290
- </button>
1291
- </div>
1292
- )}
1293
- </div>
1396
+ <span style={styles.selectionActionsDivider} aria-hidden="true" />
1397
+ <button
1398
+ type="button"
1399
+ style={styles.selectionActionButton}
1400
+ title={copy.previewSelectionTitle}
1401
+ disabled={!canPreviewSelection}
1402
+ onClick={previewSelection}
1403
+ >
1404
+ <span style={styles.selectionActionIcon} aria-hidden="true">
1405
+
1406
+ </span>
1407
+ {copy.previewSelection}
1408
+ </button>
1409
+ </div>
1410
+ )}
1411
+ </div>
1412
+ )}
1294
1413
  {syntaxError && (
1295
1414
  <p style={styles.error} role="alert">
1296
1415
  {copy.syntaxError}: {syntaxError.message}
@@ -13,6 +13,7 @@ export type SsmlEditorInsertionButton =
13
13
  | "mark"
14
14
  | "bookmark"
15
15
  | "mstts:silence"
16
+ | "mstts:audioduration"
16
17
  | "mstts:viseme"
17
18
  | (string & {});
18
19
 
@@ -0,0 +1,240 @@
1
+ import { useMemo, useState } from "react";
2
+ import type { ReactElement } from "react";
3
+ import {
4
+ buildSsml,
5
+ validateAzureSsml,
6
+ type SsmlDocument,
7
+ type SsmlElement,
8
+ type SsmlNode,
9
+ } from "@ssml-builder-js/ssml-core";
10
+
11
+ export interface VisualSsmlEditorProps {
12
+ document: SsmlDocument;
13
+ readOnly?: boolean;
14
+ onChange?: (document: SsmlDocument) => void;
15
+ onPreviewSelection?: (ssml: string) => void;
16
+ }
17
+
18
+ interface TextLeaf {
19
+ path: number[];
20
+ value: string;
21
+ ancestors: string[];
22
+ }
23
+
24
+ function isElement(node: SsmlNode): node is SsmlElement {
25
+ return typeof node !== "string" && node.type !== "text";
26
+ }
27
+
28
+ function elementLabel(element: SsmlElement): string {
29
+ return element.type === "custom" || element.type === "element" ? element.name : element.type;
30
+ }
31
+
32
+ function collectTextLeaves(nodes: SsmlNode[], path: number[] = [], ancestors: string[] = []): TextLeaf[] {
33
+ return nodes.flatMap((node, index) => {
34
+ const currentPath = [...path, index];
35
+ if (typeof node === "string") return [{ ancestors, path: currentPath, value: node }];
36
+ if (node.type === "text") return [{ ancestors, path: currentPath, value: node.value }];
37
+ return collectTextLeaves(node.children ?? [], currentPath, [...ancestors, elementLabel(node)]);
38
+ });
39
+ }
40
+
41
+ function updateNodesAtPath(
42
+ nodes: SsmlNode[],
43
+ path: number[],
44
+ update: (node: SsmlNode) => SsmlNode | SsmlNode[],
45
+ ): SsmlNode[] {
46
+ if (path.length === 0) return nodes;
47
+ const [index, ...rest] = path;
48
+ return nodes.flatMap((node, nodeIndex) => {
49
+ if (nodeIndex !== index) return [node];
50
+ if (rest.length === 0) {
51
+ const next = update(node);
52
+ return Array.isArray(next) ? next : [next];
53
+ }
54
+ if (!isElement(node)) return [node];
55
+ return [{ ...node, children: updateNodesAtPath(node.children ?? [], rest, update) }];
56
+ });
57
+ }
58
+
59
+ function updateTextAtPath(document: SsmlDocument, path: number[], value: string): SsmlDocument {
60
+ return { ...document, children: updateNodesAtPath(document.children ?? [], path, () => value) };
61
+ }
62
+
63
+ function wrapTextAtPath(
64
+ document: SsmlDocument,
65
+ path: number[],
66
+ start: number,
67
+ end: number,
68
+ tag: SsmlElement["type"],
69
+ attributes: Record<string, string>,
70
+ ): SsmlDocument {
71
+ return {
72
+ ...document,
73
+ children: updateNodesAtPath(document.children ?? [], path, (node) => {
74
+ const value = typeof node === "string" ? node : node.type === "text" ? node.value : "";
75
+ if (!value || start === end) return node;
76
+ if (tag === "break") {
77
+ return [
78
+ value.slice(0, start),
79
+ { type: tag, attributes, children: [] } as SsmlElement,
80
+ value.slice(start),
81
+ ].filter((part) => (typeof part === "string" ? part.length > 0 : true));
82
+ }
83
+ const selected = value.slice(start, end);
84
+ const wrapped = { type: tag, attributes, children: [selected] } as SsmlElement;
85
+ return [value.slice(0, start), wrapped, value.slice(end)].filter((part) =>
86
+ typeof part === "string" ? part.length > 0 : true,
87
+ );
88
+ }),
89
+ };
90
+ }
91
+
92
+ function TreeNode({
93
+ node,
94
+ path,
95
+ selectedPath,
96
+ onSelect,
97
+ }: {
98
+ node: SsmlNode;
99
+ path: number[];
100
+ selectedPath: number[] | null;
101
+ onSelect: (path: number[]) => void;
102
+ }): ReactElement | null {
103
+ if (!isElement(node)) return null;
104
+ const name = elementLabel(node);
105
+ const isSelected = selectedPath?.join(".") === path.join(".");
106
+ return (
107
+ <li>
108
+ <button type="button" aria-current={isSelected ? "true" : undefined} onClick={() => onSelect(path)}>
109
+ {`<${name}>`}
110
+ </button>
111
+ {(node.children ?? []).length > 0 && (
112
+ <ul>
113
+ {node.children?.map((child, index) => (
114
+ <TreeNode
115
+ key={`${path.join(".")}/${isElement(child) ? elementLabel(child) : JSON.stringify(child)}`}
116
+ node={child}
117
+ path={[...path, index]}
118
+ selectedPath={selectedPath}
119
+ onSelect={onSelect}
120
+ />
121
+ ))}
122
+ </ul>
123
+ )}
124
+ </li>
125
+ );
126
+ }
127
+
128
+ export function VisualSsmlEditor({
129
+ document,
130
+ readOnly = false,
131
+ onChange,
132
+ onPreviewSelection,
133
+ }: VisualSsmlEditorProps): ReactElement {
134
+ const [selectedPath, setSelectedPath] = useState<number[] | null>(null);
135
+ const [selection, setSelection] = useState({ start: 0, end: 0 });
136
+ const textLeaves = useMemo(() => collectTextLeaves(document.children ?? []), [document]);
137
+ const selectedLeaf = textLeaves.find((leaf) => leaf.path.join(".") === selectedPath?.join(".")) ?? textLeaves[0];
138
+ const diagnostics = useMemo(() => validateAzureSsml(buildSsml(document)), [document]);
139
+ const commit = (nextDocument: SsmlDocument): void => onChange?.(nextDocument);
140
+
141
+ const applyWrapper = (tag: SsmlElement["type"], attributes: Record<string, string>): void => {
142
+ if (readOnly || !selectedLeaf) return;
143
+ const start = selection.start === selection.end ? 0 : selection.start;
144
+ const end = selection.start === selection.end ? selectedLeaf.value.length : selection.end;
145
+ commit(wrapTextAtPath(document, selectedLeaf.path, start, end, tag, attributes));
146
+ };
147
+
148
+ return (
149
+ <div className="ssml-editor-visual" data-ssml-editor-visual="">
150
+ <fieldset className="ssml-editor-visual-breadcrumb">
151
+ <legend>SSML structure breadcrumb</legend>
152
+ <span>&lt;speak&gt;</span>
153
+ {selectedLeaf?.ancestors.map((ancestor, index) => (
154
+ <span key={selectedLeaf?.ancestors.slice(0, index + 1).join("/")}> / &lt;{ancestor}&gt;</span>
155
+ ))}
156
+ {selectedPath && (
157
+ <button type="button" onClick={() => setSelectedPath(null)}>
158
+ Clear parent
159
+ </button>
160
+ )}
161
+ </fieldset>
162
+ {diagnostics.length > 0 && (
163
+ <div role="alert" aria-invalid="true" className="ssml-editor-visual-errors">
164
+ {diagnostics.map((diagnostic) => (
165
+ <div key={`${diagnostic.code}-${diagnostic.message}`}>{diagnostic.message}</div>
166
+ ))}
167
+ </div>
168
+ )}
169
+ <div className="ssml-editor-visual-layout">
170
+ <nav aria-label="SSML structure tree" className="ssml-editor-visual-tree">
171
+ <strong>Structure</strong>
172
+ <ul>
173
+ {document.children?.map((node, index) => (
174
+ <TreeNode
175
+ key={isElement(node) ? elementLabel(node) : JSON.stringify(node)}
176
+ node={node}
177
+ path={[index]}
178
+ selectedPath={selectedPath}
179
+ onSelect={setSelectedPath}
180
+ />
181
+ ))}
182
+ </ul>
183
+ </nav>
184
+ <div className="ssml-editor-visual-form">
185
+ {selectedLeaf ? (
186
+ <>
187
+ <label>
188
+ Text
189
+ <textarea
190
+ value={selectedLeaf.value}
191
+ readOnly={readOnly}
192
+ onSelect={(event) =>
193
+ setSelection({ start: event.currentTarget.selectionStart, end: event.currentTarget.selectionEnd })
194
+ }
195
+ onChange={(event) => commit(updateTextAtPath(document, selectedLeaf.path, event.target.value))}
196
+ />
197
+ </label>
198
+ <fieldset className="ssml-editor-visual-actions">
199
+ <legend>Apply SSML formatting</legend>
200
+ <button type="button" disabled={readOnly} onClick={() => applyWrapper("prosody", { rate: "slow" })}>
201
+ Rate
202
+ </button>
203
+ <button type="button" disabled={readOnly} onClick={() => applyWrapper("prosody", { pitch: "high" })}>
204
+ Pitch
205
+ </button>
206
+ <button
207
+ type="button"
208
+ disabled={readOnly}
209
+ onClick={() => applyWrapper("mstts:express-as", { style: "cheerful" })}
210
+ >
211
+ Emotion
212
+ </button>
213
+ <button type="button" disabled={readOnly} onClick={() => applyWrapper("break", { time: "500ms" })}>
214
+ Pause
215
+ </button>
216
+ <button
217
+ type="button"
218
+ disabled={readOnly}
219
+ onClick={() => applyWrapper("phoneme", { alphabet: "ipa", ph: selectedLeaf.value })}
220
+ >
221
+ Pronunciation
222
+ </button>
223
+ {onPreviewSelection && (
224
+ <button
225
+ type="button"
226
+ onClick={() => onPreviewSelection(buildSsml({ ...document, children: [selectedLeaf.value] }))}
227
+ >
228
+ Preview selection
229
+ </button>
230
+ )}
231
+ </fieldset>
232
+ </>
233
+ ) : (
234
+ <p>Select an element or text node to edit it.</p>
235
+ )}
236
+ </div>
237
+ </div>
238
+ </div>
239
+ );
240
+ }
@@ -0,0 +1,94 @@
1
+ /** Generated by scripts/sync-azure-voices.ts. */
2
+ export const AZURE_VOICE_STYLE_MAP = {
3
+ "de-DE-ConradNeural": ["cheerful", "sad"],
4
+ "de-DE-KatjaNeural": ["cheerful", "sad"],
5
+ "en-US-AndrewNeural": ["empathetic", "relieved"],
6
+ "en-US-GuyNeural": [
7
+ "angry",
8
+ "cheerful",
9
+ "excited",
10
+ "friendly",
11
+ "hopeful",
12
+ "newscast",
13
+ "sad",
14
+ "shouting",
15
+ "terrified",
16
+ "unfriendly",
17
+ "whispering",
18
+ ],
19
+ "en-US-JennyMultilingualNeural": [
20
+ "cheerful",
21
+ "empathetic",
22
+ "excited",
23
+ "friendly",
24
+ "hopeful",
25
+ "sad",
26
+ "shouting",
27
+ "terrified",
28
+ "unfriendly",
29
+ "whispering",
30
+ ],
31
+ "en-US-JennyNeural": [
32
+ "assistant",
33
+ "chat",
34
+ "customerservice",
35
+ "newscast",
36
+ "cheerful",
37
+ "empathetic",
38
+ "excited",
39
+ "friendly",
40
+ "hopeful",
41
+ "sad",
42
+ "shouting",
43
+ "terrified",
44
+ "unfriendly",
45
+ "whispering",
46
+ ],
47
+ "es-ES-ElviraNeural": [],
48
+ "fil-PH-AngeloNeural": [],
49
+ "fr-FR-DeniseNeural": ["cheerful", "sad"],
50
+ "fr-FR-HenriNeural": ["cheerful", "sad"],
51
+ "id-ID-GadisNeural": [],
52
+ "it-IT-ElsaNeural": ["cheerful", "sad"],
53
+ "ja-JP-KeitaNeural": ["chat"],
54
+ "ja-JP-MayuNeural": ["calm", "cheerful", "sad"],
55
+ "ja-JP-NanamiNeural": ["chat", "customerservice", "cheerful", "whispering", "sad"],
56
+ "ko-KR-SunHiNeural": ["cheerful", "sad"],
57
+ "ms-MY-YasminNeural": [],
58
+ "pt-BR-FranciscaNeural": ["calm"],
59
+ "ru-RU-SvetlanaNeural": ["cheerful", "sad", "angry", "disgruntled", "embarrassed", "fearful"],
60
+ "th-TH-PremwadeeNeural": [],
61
+ "vi-VN-HoaiMyNeural": [],
62
+ "zh-CN-XiaoxiaoNeural": [
63
+ "assistant",
64
+ "chat",
65
+ "customerservice",
66
+ "newscast",
67
+ "cheerful",
68
+ "empathetic",
69
+ "excited",
70
+ "friendly",
71
+ "hopeful",
72
+ "sad",
73
+ "terrified",
74
+ "whispering",
75
+ "poetry-reading",
76
+ "sports_commentary",
77
+ "sports_commentary_excited",
78
+ "story",
79
+ ],
80
+ "zh-CN-YunxiNeural": [
81
+ "narration-relaxed",
82
+ "embarrassed",
83
+ "fearful",
84
+ "sad",
85
+ "disgruntled",
86
+ "serious",
87
+ "angry",
88
+ "depressed",
89
+ "chat",
90
+ "cheerful",
91
+ "assistant",
92
+ ],
93
+ "zh-TW-HsiaoChenNeural": [],
94
+ } as const satisfies Readonly<Record<string, readonly string[]>>;