@ssml-builder-js/ssml-editor-react 2.13.0 → 2.15.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 +22 -0
- package/dist/index.d.mts +14 -1
- package/dist/index.d.ts +14 -1
- package/dist/index.js +148 -25
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +148 -25
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/SsmlEditor.tsx +8 -0
- package/src/components/VisualSsmlEditor.tsx +165 -17
- package/test/ui-components.test.tsx +28 -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.15.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.15.0"
|
|
56
56
|
}
|
|
57
57
|
}
|
package/src/SsmlEditor.tsx
CHANGED
|
@@ -781,6 +781,10 @@ export interface SsmlEditorProps {
|
|
|
781
781
|
voiceRegion?: string;
|
|
782
782
|
/** Optional style filter for the visual voice selector. */
|
|
783
783
|
voiceStyle?: string;
|
|
784
|
+
/** Selected Azure voice model used for live capability validation. */
|
|
785
|
+
voiceModel?: string;
|
|
786
|
+
/** Alias for voiceModel. */
|
|
787
|
+
model?: string;
|
|
784
788
|
/** Candidate style values shown by the built-in emotion insertion. */
|
|
785
789
|
emotionStyles?: readonly string[];
|
|
786
790
|
/** Class name applied to the editor container. */
|
|
@@ -888,6 +892,8 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
|
|
|
888
892
|
voiceLocale,
|
|
889
893
|
voiceRegion,
|
|
890
894
|
voiceStyle,
|
|
895
|
+
voiceModel,
|
|
896
|
+
model,
|
|
891
897
|
emotionStyles,
|
|
892
898
|
className,
|
|
893
899
|
style,
|
|
@@ -1377,6 +1383,8 @@ export const SsmlEditor = forwardRef<SsmlEditorRef, SsmlEditorProps>(function Ss
|
|
|
1377
1383
|
voiceLocale={voiceLocale}
|
|
1378
1384
|
voiceRegion={voiceRegion}
|
|
1379
1385
|
voiceStyle={voiceStyle}
|
|
1386
|
+
voiceModel={voiceModel}
|
|
1387
|
+
model={model}
|
|
1380
1388
|
/>
|
|
1381
1389
|
</div>
|
|
1382
1390
|
) : (
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type SsmlDocument,
|
|
7
7
|
type SsmlElement,
|
|
8
8
|
type SsmlNode,
|
|
9
|
+
type SsmlDiagnostic,
|
|
9
10
|
} from "@ssml-builder-js/ssml-core";
|
|
10
11
|
import { SSML_ELEMENT_COPY, type SsmlEditorLocale } from "../locales";
|
|
11
12
|
|
|
@@ -13,7 +14,11 @@ export interface VisualVoiceCatalogEntry {
|
|
|
13
14
|
name: string;
|
|
14
15
|
locale: string;
|
|
15
16
|
region?: string;
|
|
17
|
+
regions?: readonly string[];
|
|
16
18
|
styles?: readonly string[];
|
|
19
|
+
supportedTags?: readonly string[];
|
|
20
|
+
unsupportedTags?: readonly string[];
|
|
21
|
+
models?: readonly string[];
|
|
17
22
|
preview?: boolean;
|
|
18
23
|
status?: "ga" | "preview" | "deprecated";
|
|
19
24
|
}
|
|
@@ -21,6 +26,7 @@ export interface VisualVoiceCatalogEntry {
|
|
|
21
26
|
export interface VoiceSelectorRenderProps {
|
|
22
27
|
value: string;
|
|
23
28
|
voices: readonly VisualVoiceCatalogEntry[];
|
|
29
|
+
selectedVoice?: VisualVoiceCatalogEntry;
|
|
24
30
|
readOnly: boolean;
|
|
25
31
|
locale: SsmlEditorLocale;
|
|
26
32
|
onChange: (voice: string) => void;
|
|
@@ -49,6 +55,10 @@ export interface VisualSsmlEditorProps {
|
|
|
49
55
|
voiceLocale?: string;
|
|
50
56
|
voiceRegion?: string;
|
|
51
57
|
voiceStyle?: string;
|
|
58
|
+
/** Selected Azure voice model used for live capability validation. */
|
|
59
|
+
voiceModel?: string;
|
|
60
|
+
/** Alias for voiceModel. */
|
|
61
|
+
model?: string;
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
interface TextLeaf {
|
|
@@ -240,28 +250,127 @@ function filteredVoices(
|
|
|
240
250
|
): readonly VisualVoiceCatalogEntry[] {
|
|
241
251
|
return (voiceCatalog ?? []).filter((voice) => {
|
|
242
252
|
if (locale && voice.locale.toLowerCase() !== locale.toLowerCase()) return false;
|
|
243
|
-
|
|
253
|
+
const regions = [voice.region, ...(voice.regions ?? [])].filter((candidate): candidate is string =>
|
|
254
|
+
Boolean(candidate),
|
|
255
|
+
);
|
|
256
|
+
if (region && regions.length > 0 && !regions.some((candidate) => candidate.toLowerCase() === region.toLowerCase()))
|
|
257
|
+
return false;
|
|
244
258
|
if (style && !voice.styles?.some((candidate) => candidate.toLowerCase() === style.toLowerCase())) return false;
|
|
245
259
|
return true;
|
|
246
260
|
});
|
|
247
261
|
}
|
|
248
262
|
|
|
249
|
-
function
|
|
263
|
+
function VoiceCapabilityMatrix({
|
|
264
|
+
voice,
|
|
265
|
+
locale,
|
|
266
|
+
}: {
|
|
267
|
+
voice: VisualVoiceCatalogEntry;
|
|
268
|
+
locale: SsmlEditorLocale;
|
|
269
|
+
}): ReactElement {
|
|
270
|
+
const status = voice.status ?? (voice.preview ? "preview" : undefined);
|
|
271
|
+
return (
|
|
272
|
+
<div className="ssml-editor-voice-capabilities" data-voice-capabilities="">
|
|
273
|
+
<div>
|
|
274
|
+
{locale === "ja" ? "ステータス" : "Status"}: {status?.toUpperCase() ?? (locale === "ja" ? "GA" : "GA")}
|
|
275
|
+
{status === "preview" || status === "deprecated" ? (
|
|
276
|
+
<span role="status" aria-label={status} className="ssml-editor-voice-warning-badge">
|
|
277
|
+
{status === "preview" ? "⚠ Preview" : "⚠ Deprecated"}
|
|
278
|
+
</span>
|
|
279
|
+
) : null}
|
|
280
|
+
</div>
|
|
281
|
+
{(voice.regions?.length || voice.region) && (
|
|
282
|
+
<div>
|
|
283
|
+
{locale === "ja" ? "リージョン" : "Regions"}:{" "}
|
|
284
|
+
{[...(voice.regions ?? []), ...(voice.region ? [voice.region] : [])]
|
|
285
|
+
.filter((region, index, all) => all.indexOf(region) === index)
|
|
286
|
+
.join(", ")}
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
{voice.supportedTags && voice.supportedTags.length > 0 && (
|
|
290
|
+
<div>
|
|
291
|
+
{locale === "ja" ? "対応タグ" : "Supported tags"}: {voice.supportedTags.join(", ")}
|
|
292
|
+
</div>
|
|
293
|
+
)}
|
|
294
|
+
{voice.unsupportedTags && voice.unsupportedTags.length > 0 && (
|
|
295
|
+
<div className="ssml-editor-voice-unsupported">
|
|
296
|
+
{locale === "ja" ? "非対応タグ" : "Unsupported tags"}: {voice.unsupportedTags.join(", ")}
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function diagnosticMatchesElement(diagnostic: SsmlDiagnostic, element: SsmlElement): boolean {
|
|
304
|
+
const name = elementLabel(element).toLowerCase().replace("expressas", "mstts:express-as");
|
|
305
|
+
if (diagnostic.code === "azure-unsupported-style") return name === "mstts:express-as" || name === "express-as";
|
|
306
|
+
if (diagnostic.code === "azure-unsupported-tag-for-voice") {
|
|
307
|
+
return (
|
|
308
|
+
diagnostic.message.toLowerCase().includes(`<${name}>`) ||
|
|
309
|
+
diagnostic.message.toLowerCase().includes(`<${name.replace("mstts:", "")}>`)
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
if (diagnostic.code === "azure-locale-mismatch" || diagnostic.code === "azure-unsupported-model-for-voice")
|
|
313
|
+
return name === "voice" || name === "mstts:turn";
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function elementWarnings(diagnostics: readonly SsmlDiagnostic[], element: SsmlElement): readonly SsmlDiagnostic[] {
|
|
318
|
+
return diagnostics.filter((diagnostic) => diagnosticMatchesElement(diagnostic, element));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function fieldWarnings(
|
|
322
|
+
diagnostics: readonly SsmlDiagnostic[],
|
|
323
|
+
element: SsmlElement,
|
|
324
|
+
field: VisualElementField,
|
|
325
|
+
): readonly SsmlDiagnostic[] {
|
|
326
|
+
return elementWarnings(diagnostics, element).filter((diagnostic) => {
|
|
327
|
+
if (diagnostic.code === "azure-unsupported-style") return field.key === "style";
|
|
328
|
+
return field.key === "name" || field.key === "voice";
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function WarningBadge({ messages }: { messages: readonly SsmlDiagnostic[] }): ReactElement | null {
|
|
333
|
+
if (messages.length === 0) return null;
|
|
250
334
|
return (
|
|
251
|
-
<
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
335
|
+
<span
|
|
336
|
+
role="status"
|
|
337
|
+
aria-label={messages.map((message) => message.message).join(" ")}
|
|
338
|
+
title={messages.map((message) => message.message).join(" ")}
|
|
339
|
+
data-ssml-warning-badge=""
|
|
340
|
+
data-testid="ssml-editor-warning-badge"
|
|
341
|
+
className="ssml-editor-warning-badge"
|
|
256
342
|
>
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
343
|
+
⚠
|
|
344
|
+
</span>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function DefaultVoiceSelector({
|
|
349
|
+
value,
|
|
350
|
+
voices,
|
|
351
|
+
selectedVoice,
|
|
352
|
+
readOnly,
|
|
353
|
+
locale,
|
|
354
|
+
onChange,
|
|
355
|
+
}: VoiceSelectorRenderProps): ReactElement {
|
|
356
|
+
return (
|
|
357
|
+
<>
|
|
358
|
+
<select
|
|
359
|
+
aria-label={locale === "ja" ? "音声" : "Voice"}
|
|
360
|
+
value={value}
|
|
361
|
+
disabled={readOnly}
|
|
362
|
+
onChange={(event) => onChange(event.target.value)}
|
|
363
|
+
>
|
|
364
|
+
<option value="">{locale === "ja" ? "音声を選択" : "Select a voice"}</option>
|
|
365
|
+
{voices.map((voice) => (
|
|
366
|
+
<option key={voice.name} value={voice.name}>
|
|
367
|
+
{voice.name}
|
|
368
|
+
{voice.preview || voice.status === "preview" ? " (preview)" : ""}
|
|
369
|
+
</option>
|
|
370
|
+
))}
|
|
371
|
+
</select>
|
|
372
|
+
{selectedVoice ? <VoiceCapabilityMatrix voice={selectedVoice} locale={locale} /> : null}
|
|
373
|
+
</>
|
|
265
374
|
);
|
|
266
375
|
}
|
|
267
376
|
|
|
@@ -278,6 +387,7 @@ function VisualElementInspector({
|
|
|
278
387
|
voiceLocale,
|
|
279
388
|
voiceRegion,
|
|
280
389
|
voiceStyle,
|
|
390
|
+
diagnostics,
|
|
281
391
|
}: {
|
|
282
392
|
document: SsmlDocument;
|
|
283
393
|
element: SsmlElement;
|
|
@@ -291,16 +401,22 @@ function VisualElementInspector({
|
|
|
291
401
|
voiceLocale?: string;
|
|
292
402
|
voiceRegion?: string;
|
|
293
403
|
voiceStyle?: string;
|
|
404
|
+
diagnostics: readonly SsmlDiagnostic[];
|
|
294
405
|
}): ReactElement {
|
|
295
406
|
if (customInspector) {
|
|
296
407
|
return <>{customInspector({ document, element, path, readOnly, onChange: commit, locale })}</>;
|
|
297
408
|
}
|
|
298
409
|
const fields = getElementFields(element);
|
|
299
410
|
const availableVoices = filteredVoices(voiceCatalog, voiceLocale, voiceRegion, voiceStyle);
|
|
411
|
+
const selectedVoice = voiceCatalog?.find(
|
|
412
|
+
(voice) => voice.name === (element.type === "voice" ? element.name : undefined),
|
|
413
|
+
);
|
|
300
414
|
const renderSelector = renderVoiceSelector ?? DefaultVoiceSelector;
|
|
301
415
|
return (
|
|
302
416
|
<fieldset>
|
|
303
|
-
<legend>
|
|
417
|
+
<legend>
|
|
418
|
+
{`<${elementLabel(element)}>`} <WarningBadge messages={elementWarnings(diagnostics, element)} />
|
|
419
|
+
</legend>
|
|
304
420
|
{fields.length === 0 ? (
|
|
305
421
|
<p>This element is preserved in the visual tree. Edit its attributes in Code mode.</p>
|
|
306
422
|
) : (
|
|
@@ -348,10 +464,12 @@ function VisualElementInspector({
|
|
|
348
464
|
} as Record<string, string>
|
|
349
465
|
)[field.key] ?? field.label)
|
|
350
466
|
: field.label}
|
|
467
|
+
<WarningBadge messages={fieldWarnings(diagnostics, element, field)} />
|
|
351
468
|
{field.key === "name" && elementLabel(element) === "voice" && voiceCatalog ? (
|
|
352
469
|
renderSelector({
|
|
353
470
|
value: inputValue,
|
|
354
471
|
voices: availableVoices,
|
|
472
|
+
selectedVoice,
|
|
355
473
|
readOnly,
|
|
356
474
|
locale,
|
|
357
475
|
onChange: (value) => commit(updateOptionalElementProperty(document, path, field.key, value)),
|
|
@@ -447,11 +565,13 @@ function TreeNode({
|
|
|
447
565
|
path,
|
|
448
566
|
selectedPath,
|
|
449
567
|
onSelect,
|
|
568
|
+
getWarnings,
|
|
450
569
|
}: {
|
|
451
570
|
node: SsmlNode;
|
|
452
571
|
path: number[];
|
|
453
572
|
selectedPath: number[] | null;
|
|
454
573
|
onSelect: (path: number[]) => void;
|
|
574
|
+
getWarnings: (element: SsmlElement) => readonly SsmlDiagnostic[];
|
|
455
575
|
}): ReactElement | null {
|
|
456
576
|
if (!isElement(node)) return null;
|
|
457
577
|
const name = elementLabel(node);
|
|
@@ -460,6 +580,7 @@ function TreeNode({
|
|
|
460
580
|
<li>
|
|
461
581
|
<button type="button" aria-current={isSelected ? "true" : undefined} onClick={() => onSelect(path)}>
|
|
462
582
|
{`<${name}>`}
|
|
583
|
+
<WarningBadge messages={getWarnings(node)} />
|
|
463
584
|
</button>
|
|
464
585
|
{(node.children ?? []).length > 0 && (
|
|
465
586
|
<ul>
|
|
@@ -470,6 +591,7 @@ function TreeNode({
|
|
|
470
591
|
path={[...path, index]}
|
|
471
592
|
selectedPath={selectedPath}
|
|
472
593
|
onSelect={onSelect}
|
|
594
|
+
getWarnings={getWarnings}
|
|
473
595
|
/>
|
|
474
596
|
))}
|
|
475
597
|
</ul>
|
|
@@ -490,6 +612,8 @@ export function VisualSsmlEditor({
|
|
|
490
612
|
voiceLocale,
|
|
491
613
|
voiceRegion,
|
|
492
614
|
voiceStyle,
|
|
615
|
+
voiceModel,
|
|
616
|
+
model,
|
|
493
617
|
}: VisualSsmlEditorProps): ReactElement {
|
|
494
618
|
const [selectedPath, setSelectedPath] = useState<number[] | null>(null);
|
|
495
619
|
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
|
@@ -500,7 +624,27 @@ export function VisualSsmlEditor({
|
|
|
500
624
|
selectedElement && isElement(selectedElement)
|
|
501
625
|
? (customInspectors?.[elementLabel(selectedElement)] ?? customInspectors?.[selectedElement.type])
|
|
502
626
|
: undefined;
|
|
503
|
-
const
|
|
627
|
+
const validationOptions = useMemo(
|
|
628
|
+
() => ({
|
|
629
|
+
model: voiceModel ?? model,
|
|
630
|
+
customVoiceDefinitions: voiceCatalog?.map((voice) => ({
|
|
631
|
+
name: voice.name,
|
|
632
|
+
locale: voice.locale,
|
|
633
|
+
regions: voice.regions ?? (voice.region ? [voice.region] : undefined),
|
|
634
|
+
styles: voice.styles,
|
|
635
|
+
supportedTags: voice.supportedTags,
|
|
636
|
+
unsupportedTags: voice.unsupportedTags,
|
|
637
|
+
models: voice.models,
|
|
638
|
+
status: voice.status ?? (voice.preview ? "preview" : "ga"),
|
|
639
|
+
})),
|
|
640
|
+
}),
|
|
641
|
+
[model, voiceCatalog, voiceModel],
|
|
642
|
+
);
|
|
643
|
+
const diagnostics = useMemo(
|
|
644
|
+
() => validateAzureSsml(buildSsml(document), validationOptions) as SsmlDiagnostic[],
|
|
645
|
+
[document, validationOptions],
|
|
646
|
+
);
|
|
647
|
+
const getWarnings = (element: SsmlElement): readonly SsmlDiagnostic[] => elementWarnings(diagnostics, element);
|
|
504
648
|
const commit = (nextDocument: SsmlDocument): void => onChange?.(nextDocument);
|
|
505
649
|
|
|
506
650
|
const applyWrapper = (tag: SsmlElement["type"], attributes: Record<string, string>): void => {
|
|
@@ -542,6 +686,7 @@ export function VisualSsmlEditor({
|
|
|
542
686
|
path={[index]}
|
|
543
687
|
selectedPath={selectedPath}
|
|
544
688
|
onSelect={setSelectedPath}
|
|
689
|
+
getWarnings={getWarnings}
|
|
545
690
|
/>
|
|
546
691
|
))}
|
|
547
692
|
</ul>
|
|
@@ -573,10 +718,12 @@ export function VisualSsmlEditor({
|
|
|
573
718
|
<legend>{localizedElementLabel(selectedElement, locale)}</legend>
|
|
574
719
|
<label htmlFor="ssml-visual-turn-voice">
|
|
575
720
|
{locale === "ja" ? "音声" : "Voice"}
|
|
721
|
+
<WarningBadge messages={elementWarnings(diagnostics, selectedElement)} />
|
|
576
722
|
{voiceCatalog ? (
|
|
577
723
|
(renderVoiceSelector ?? DefaultVoiceSelector)({
|
|
578
724
|
value: selectedElement.voice ?? "",
|
|
579
725
|
voices: filteredVoices(voiceCatalog, voiceLocale, voiceRegion, voiceStyle),
|
|
726
|
+
selectedVoice: voiceCatalog?.find((voice) => voice.name === selectedElement.voice),
|
|
580
727
|
readOnly,
|
|
581
728
|
locale,
|
|
582
729
|
onChange: (value) =>
|
|
@@ -681,6 +828,7 @@ export function VisualSsmlEditor({
|
|
|
681
828
|
voiceLocale={voiceLocale}
|
|
682
829
|
voiceRegion={voiceRegion}
|
|
683
830
|
voiceStyle={voiceStyle}
|
|
831
|
+
diagnostics={diagnostics}
|
|
684
832
|
/>
|
|
685
833
|
) : selectedLeaf ? (
|
|
686
834
|
<>
|
|
@@ -724,6 +724,34 @@ describe("SsmlEditor props", () => {
|
|
|
724
724
|
);
|
|
725
725
|
});
|
|
726
726
|
|
|
727
|
+
it("shows live capability warning badges for model, locale, and style mismatches", () => {
|
|
728
|
+
renderEditor({
|
|
729
|
+
editMode: "visual",
|
|
730
|
+
model: "neural-v2",
|
|
731
|
+
document: {
|
|
732
|
+
...editorDocument,
|
|
733
|
+
children: [
|
|
734
|
+
{
|
|
735
|
+
type: "voice",
|
|
736
|
+
name: "en-US-ExampleNeural",
|
|
737
|
+
children: [{ type: "mstts:express-as", style: "sad", children: ["Hello"] }],
|
|
738
|
+
},
|
|
739
|
+
],
|
|
740
|
+
},
|
|
741
|
+
voiceCatalog: [
|
|
742
|
+
{
|
|
743
|
+
name: "en-US-ExampleNeural",
|
|
744
|
+
locale: "ja-JP",
|
|
745
|
+
styles: ["cheerful"],
|
|
746
|
+
models: ["neural-v1"],
|
|
747
|
+
supportedTags: ["mstts:express-as"],
|
|
748
|
+
},
|
|
749
|
+
],
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
expect(screen.getAllByTestId("ssml-editor-warning-badge").length).toBeGreaterThan(0);
|
|
753
|
+
});
|
|
754
|
+
|
|
727
755
|
it("updates toolbar and popover text when the locale changes", async () => {
|
|
728
756
|
const user = userEvent.setup();
|
|
729
757
|
const { rerender } = renderEditor({ locale: "ja", showToolbarLabels: true });
|