@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssml-builder-js/ssml-editor-react",
3
- "version": "2.13.0",
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.13.0"
55
+ "@ssml-builder-js/ssml-core": "^2.15.0"
56
56
  }
57
57
  }
@@ -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
- if (region && voice.region && voice.region.toLowerCase() !== region.toLowerCase()) return false;
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 DefaultVoiceSelector({ value, voices, readOnly, locale, onChange }: VoiceSelectorRenderProps): ReactElement {
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
- <select
252
- aria-label={locale === "ja" ? "音声" : "Voice"}
253
- value={value}
254
- disabled={readOnly}
255
- onChange={(event) => onChange(event.target.value)}
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
- <option value="">{locale === "ja" ? "音声を選択" : "Select a voice"}</option>
258
- {voices.map((voice) => (
259
- <option key={voice.name} value={voice.name}>
260
- {voice.name}
261
- {voice.preview || voice.status === "preview" ? " (preview)" : ""}
262
- </option>
263
- ))}
264
- </select>
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>{`<${elementLabel(element)}>`}</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 diagnostics = useMemo(() => validateAzureSsml(buildSsml(document)), [document]);
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 });