ssml-builder-js 2.8.1 → 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/dist/elements.js CHANGED
@@ -75,7 +75,13 @@ var SSML_TAGS = {
75
75
  SILENCE: "silence",
76
76
  MSTTS_VISEME: "mstts:viseme",
77
77
  VISEME: "viseme",
78
- MSTTS_AUDIO_DURATION: "mstts:audioduration"
78
+ MSTTS_AUDIO_DURATION: "mstts:audioduration",
79
+ MSTTS_DIALOG: "mstts:dialog",
80
+ MSTTS_TURN: "mstts:turn",
81
+ MSTTS_BACKGROUND_AUDIO: "mstts:backgroundaudio",
82
+ MSTTS_TTS_EMBEDDING: "mstts:ttsembedding",
83
+ MSTTS_EMBEDDING: "mstts:embedding",
84
+ MSTTS_VOICE_CONVERSION: "mstts:voiceconversion"
79
85
  };
80
86
  var SSML_ATTRS = {
81
87
  VERSION: "version",
@@ -84,6 +90,7 @@ var SSML_ATTRS = {
84
90
  LANG: "lang",
85
91
  MSTTS_XMLNS: "xmlns:mstts",
86
92
  NAME: "name",
93
+ VOICE: "voice",
87
94
  EFFECT: "effect",
88
95
  RATE: "rate",
89
96
  PITCH: "pitch",
@@ -115,7 +122,9 @@ var SSML_ATTRS = {
115
122
  MARK: "mark",
116
123
  URI: "uri",
117
124
  TYPE: "type",
118
- VALUE: "value"
125
+ VALUE: "value",
126
+ FADE_IN: "fadein",
127
+ FADE_OUT: "fadeout"
119
128
  };
120
129
  function escapeText(value) {
121
130
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -205,6 +214,20 @@ function getAttributes(element) {
205
214
  case SSML_TAGS.MSTTS_AUDIO_DURATION:
206
215
  addAttribute(attributes, SSML_ATTRS.VALUE, element.value);
207
216
  break;
217
+ case SSML_TAGS.MSTTS_TURN:
218
+ addAttribute(attributes, SSML_ATTRS.VOICE, element.voice);
219
+ break;
220
+ case SSML_TAGS.MSTTS_BACKGROUND_AUDIO:
221
+ addAttribute(attributes, SSML_ATTRS.SRC, element.src);
222
+ addAttribute(attributes, SSML_ATTRS.VOLUME, element.volume);
223
+ addAttribute(attributes, SSML_ATTRS.FADE_IN, element.fadeIn ?? element.fadein);
224
+ addAttribute(attributes, SSML_ATTRS.FADE_OUT, element.fadeOut ?? element.fadeout);
225
+ break;
226
+ case SSML_TAGS.MSTTS_DIALOG:
227
+ case SSML_TAGS.MSTTS_TTS_EMBEDDING:
228
+ case SSML_TAGS.MSTTS_EMBEDDING:
229
+ case SSML_TAGS.MSTTS_VOICE_CONVERSION:
230
+ break;
208
231
  case SSML_TAGS.PARAGRAPH:
209
232
  case SSML_TAGS.SENTENCE:
210
233
  case SSML_TAGS.WORD:
@@ -769,6 +792,40 @@ function convertElement(node) {
769
792
  if (value !== void 0) element.value = value;
770
793
  return finishElement(element, node, attributes);
771
794
  }
795
+ case SSML_TAGS.MSTTS_DIALOG: {
796
+ const element = { type: SSML_TAGS.MSTTS_DIALOG };
797
+ return finishElement(element, node, attributes);
798
+ }
799
+ case SSML_TAGS.MSTTS_TURN: {
800
+ const element = { type: SSML_TAGS.MSTTS_TURN };
801
+ const voice = readAttribute(attributes, SSML_ATTRS.VOICE);
802
+ if (voice !== void 0) element.voice = voice;
803
+ return finishElement(element, node, attributes);
804
+ }
805
+ case SSML_TAGS.MSTTS_BACKGROUND_AUDIO: {
806
+ const element = { type: SSML_TAGS.MSTTS_BACKGROUND_AUDIO };
807
+ const src = readAttribute(attributes, SSML_ATTRS.SRC);
808
+ const volume = readAttribute(attributes, SSML_ATTRS.VOLUME);
809
+ const fadeIn = readAttribute(attributes, SSML_ATTRS.FADE_IN);
810
+ const fadeOut = readAttribute(attributes, SSML_ATTRS.FADE_OUT);
811
+ if (src !== void 0) element.src = src;
812
+ if (volume !== void 0) element.volume = volume;
813
+ if (fadeIn !== void 0) element.fadeIn = fadeIn;
814
+ if (fadeOut !== void 0) element.fadeOut = fadeOut;
815
+ return finishElement(element, node, attributes);
816
+ }
817
+ case SSML_TAGS.MSTTS_TTS_EMBEDDING: {
818
+ const element = { type: SSML_TAGS.MSTTS_TTS_EMBEDDING };
819
+ return finishElement(element, node, attributes);
820
+ }
821
+ case SSML_TAGS.MSTTS_EMBEDDING: {
822
+ const element = { type: SSML_TAGS.MSTTS_EMBEDDING };
823
+ return finishElement(element, node, attributes);
824
+ }
825
+ case SSML_TAGS.MSTTS_VOICE_CONVERSION: {
826
+ const element = { type: SSML_TAGS.MSTTS_VOICE_CONVERSION };
827
+ return finishElement(element, node, attributes);
828
+ }
772
829
  default: {
773
830
  const element = {
774
831
  name: node.name,
@@ -810,6 +867,644 @@ function parseSsml(xmlString) {
810
867
  }
811
868
  return document2;
812
869
  }
870
+ var AZURE_VOICE_DEFINITIONS = [
871
+ { name: "de-DE-ConradNeural", locale: "de-DE", styles: ["cheerful", "sad"] },
872
+ { name: "de-DE-KatjaNeural", locale: "de-DE", styles: ["cheerful", "sad"] },
873
+ { name: "en-US-AndrewNeural", locale: "en-US", styles: ["empathetic", "relieved"] },
874
+ {
875
+ name: "en-US-GuyNeural",
876
+ locale: "en-US",
877
+ styles: [
878
+ "angry",
879
+ "cheerful",
880
+ "excited",
881
+ "friendly",
882
+ "hopeful",
883
+ "newscast",
884
+ "sad",
885
+ "shouting",
886
+ "terrified",
887
+ "unfriendly",
888
+ "whispering"
889
+ ]
890
+ },
891
+ {
892
+ name: "en-US-JennyMultilingualNeural",
893
+ locale: "en-US",
894
+ styles: [
895
+ "cheerful",
896
+ "empathetic",
897
+ "excited",
898
+ "friendly",
899
+ "hopeful",
900
+ "sad",
901
+ "shouting",
902
+ "terrified",
903
+ "unfriendly",
904
+ "whispering"
905
+ ]
906
+ },
907
+ {
908
+ name: "en-US-JennyNeural",
909
+ locale: "en-US",
910
+ styles: [
911
+ "assistant",
912
+ "chat",
913
+ "customerservice",
914
+ "newscast",
915
+ "cheerful",
916
+ "empathetic",
917
+ "excited",
918
+ "friendly",
919
+ "hopeful",
920
+ "sad",
921
+ "shouting",
922
+ "terrified",
923
+ "unfriendly",
924
+ "whispering"
925
+ ]
926
+ },
927
+ { name: "es-ES-ElviraNeural", locale: "es-ES" },
928
+ { name: "fil-PH-AngeloNeural", locale: "fil-PH" },
929
+ { name: "fr-FR-DeniseNeural", locale: "fr-FR", styles: ["cheerful", "sad"] },
930
+ { name: "fr-FR-HenriNeural", locale: "fr-FR", styles: ["cheerful", "sad"] },
931
+ { name: "id-ID-GadisNeural", locale: "id-ID" },
932
+ { name: "it-IT-ElsaNeural", locale: "it-IT", styles: ["cheerful", "sad"] },
933
+ { name: "ja-JP-KeitaNeural", locale: "ja-JP", styles: ["chat"] },
934
+ { name: "ja-JP-MayuNeural", locale: "ja-JP", styles: ["calm", "cheerful", "sad"] },
935
+ { name: "ja-JP-NanamiNeural", locale: "ja-JP", styles: ["chat", "customerservice", "cheerful", "whispering", "sad"] },
936
+ { name: "ko-KR-SunHiNeural", locale: "ko-KR", styles: ["cheerful", "sad"] },
937
+ { name: "ms-MY-YasminNeural", locale: "ms-MY" },
938
+ { name: "pt-BR-FranciscaNeural", locale: "pt-BR", styles: ["calm"] },
939
+ {
940
+ name: "ru-RU-SvetlanaNeural",
941
+ locale: "ru-RU",
942
+ styles: ["cheerful", "sad", "angry", "disgruntled", "embarrassed", "fearful"]
943
+ },
944
+ { name: "th-TH-PremwadeeNeural", locale: "th-TH" },
945
+ { name: "vi-VN-HoaiMyNeural", locale: "vi-VN" },
946
+ {
947
+ name: "zh-CN-XiaoxiaoNeural",
948
+ locale: "zh-CN",
949
+ styles: [
950
+ "assistant",
951
+ "chat",
952
+ "customerservice",
953
+ "newscast",
954
+ "cheerful",
955
+ "empathetic",
956
+ "excited",
957
+ "friendly",
958
+ "hopeful",
959
+ "sad",
960
+ "terrified",
961
+ "whispering",
962
+ "poetry-reading",
963
+ "sports_commentary",
964
+ "sports_commentary_excited",
965
+ "story"
966
+ ]
967
+ },
968
+ {
969
+ name: "zh-CN-YunxiNeural",
970
+ locale: "zh-CN",
971
+ styles: [
972
+ "narration-relaxed",
973
+ "embarrassed",
974
+ "fearful",
975
+ "sad",
976
+ "disgruntled",
977
+ "serious",
978
+ "angry",
979
+ "depressed",
980
+ "chat",
981
+ "cheerful",
982
+ "assistant"
983
+ ]
984
+ },
985
+ { name: "zh-TW-HsiaoChenNeural", locale: "zh-TW" }
986
+ ];
987
+ var ALLOWED_BREAK_STRENGTHS = /* @__PURE__ */ new Set(["none", "x-weak", "weak", "medium", "strong", "x-strong"]);
988
+ var ALLOWED_SAY_AS = /* @__PURE__ */ new Set([
989
+ "characters",
990
+ "spell-out",
991
+ "cardinal",
992
+ "ordinal",
993
+ "number",
994
+ "date",
995
+ "time",
996
+ "telephone",
997
+ "fraction",
998
+ "address",
999
+ "name",
1000
+ "currency",
1001
+ "number_digit"
1002
+ ]);
1003
+ var ALLOWED_ROLES = /* @__PURE__ */ new Set([
1004
+ "Girl",
1005
+ "Boy",
1006
+ "YoungAdultFemale",
1007
+ "YoungAdultMale",
1008
+ "OlderAdultFemale",
1009
+ "OlderAdultMale",
1010
+ "SeniorFemale",
1011
+ "SeniorMale"
1012
+ ]);
1013
+ var ALLOWED_EMPHASIS_LEVELS = /* @__PURE__ */ new Set(["strong", "moderate", "reduced", "none"]);
1014
+ var ALLOWED_SILENCE_TYPES = /* @__PURE__ */ new Set([
1015
+ "Leading",
1016
+ "Tailing",
1017
+ "Sentenceboundary",
1018
+ "Comma",
1019
+ "Semicolon",
1020
+ "Enumerationcomma"
1021
+ ]);
1022
+ var ALLOWED_VISEME_TYPES = /* @__PURE__ */ new Set(["redlips_front", "FacialExpression"]);
1023
+ function decodeAttribute(value) {
1024
+ return value.replace(
1025
+ /&(?:amp|apos|gt|lt|quot);/gi,
1026
+ (entity) => ({ "&amp;": "&", "&apos;": "'", "&gt;": ">", "&lt;": "<", "&quot;": '"' })[entity.toLowerCase()] ?? entity
1027
+ );
1028
+ }
1029
+ function findTagEnd2(source, start) {
1030
+ let quote = "";
1031
+ for (let index = start; index < source.length; index += 1) {
1032
+ const character = source[index];
1033
+ if (quote) {
1034
+ if (character === quote) quote = "";
1035
+ } else if (character === '"' || character === "'") quote = character;
1036
+ else if (character === ">") return index;
1037
+ }
1038
+ return source.length - 1;
1039
+ }
1040
+ function tokenizeElements(source) {
1041
+ const tokens = [];
1042
+ const openElements = [];
1043
+ let index = 0;
1044
+ while (index < source.length) {
1045
+ const start = source.indexOf("<", index);
1046
+ if (start === -1) break;
1047
+ if (source.startsWith("<!--", start)) {
1048
+ const end2 = source.indexOf("-->", start + 4);
1049
+ index = end2 === -1 ? source.length : end2 + 3;
1050
+ continue;
1051
+ }
1052
+ if (source.startsWith("<![CDATA[", start)) {
1053
+ const end2 = source.indexOf("]]>", start + 9);
1054
+ index = end2 === -1 ? source.length : end2 + 3;
1055
+ continue;
1056
+ }
1057
+ if (source.startsWith("<?", start)) {
1058
+ const end2 = source.indexOf("?>", start + 2);
1059
+ index = end2 === -1 ? source.length : end2 + 2;
1060
+ continue;
1061
+ }
1062
+ const end = findTagEnd2(source, start + 1);
1063
+ const raw = source.slice(start, end + 1);
1064
+ if (raw.startsWith("</")) {
1065
+ openElements.pop();
1066
+ index = end + 1;
1067
+ continue;
1068
+ }
1069
+ const nameMatch = /^<\s*([A-Za-z_][A-Za-z0-9_.:-]*)/.exec(raw);
1070
+ if (!nameMatch?.[1]) {
1071
+ index = end + 1;
1072
+ continue;
1073
+ }
1074
+ const attributes = /* @__PURE__ */ new Map();
1075
+ const attributeSource = raw.slice(nameMatch[0].length, raw.length - 1).replace(/\/\s*$/, "");
1076
+ const attributePattern = /([A-Za-z_][A-Za-z0-9_.:-]*)\s*=\s*(["'])([\s\S]*?)\2/g;
1077
+ for (const match of attributeSource.matchAll(attributePattern)) {
1078
+ attributes.set(match[1].toLowerCase(), decodeAttribute(match[3]));
1079
+ }
1080
+ const selfClosing = /\/\s*>$/.test(raw);
1081
+ const parent = openElements[openElements.length - 1];
1082
+ const parentVoiceName = [...openElements].reverse().find((element) => element.voiceName)?.voiceName;
1083
+ const tokenName = nameMatch[1];
1084
+ const tokenVoiceName = tokenName.toLowerCase() === "voice" ? attributes.get("name") : tokenName.toLowerCase() === "mstts:turn" ? attributes.get("voice") ?? parentVoiceName : parentVoiceName;
1085
+ tokens.push({
1086
+ attributes,
1087
+ end,
1088
+ name: tokenName,
1089
+ parentName: parent?.name,
1090
+ parentVoiceName,
1091
+ selfClosing,
1092
+ start
1093
+ });
1094
+ if (!selfClosing) {
1095
+ openElements.push({
1096
+ name: tokenName,
1097
+ voiceName: tokenVoiceName
1098
+ });
1099
+ }
1100
+ index = end + 1;
1101
+ }
1102
+ return tokens;
1103
+ }
1104
+ function location(source, offset) {
1105
+ const before = source.slice(0, Math.max(0, offset));
1106
+ const line = before.split("\n").length;
1107
+ return { line, column: before.length - (before.lastIndexOf("\n") + 1) + 1 };
1108
+ }
1109
+ function addDiagnostic(diagnostics, source, offset, message, severity = "error", code2) {
1110
+ diagnostics.push({
1111
+ ...location(source, offset),
1112
+ message,
1113
+ severity,
1114
+ source: "ssml-static-validator",
1115
+ ...code2 ? { code: code2 } : {}
1116
+ });
1117
+ }
1118
+ function isSupportedProsodyRate(value) {
1119
+ const trimmed = value.trim();
1120
+ if (/^(x-slow|slow|medium|fast|x-fast|[+-]?\d+(?:\.\d+)?%)$/.test(trimmed)) return true;
1121
+ const multiplier = /^(\d+(?:\.\d+)?)(x)?$/i.exec(trimmed);
1122
+ if (!multiplier) return false;
1123
+ const numericValue = Number(multiplier[1]);
1124
+ return numericValue >= 0.5 && numericValue <= 2;
1125
+ }
1126
+ function isValidAzureAudioDuration(value) {
1127
+ const trimmed = value.trim();
1128
+ const numeric = /^(\d+(?:\.\d+)?)(ms|s)$/.exec(trimmed);
1129
+ if (numeric) return Number(numeric[1]) > 0;
1130
+ const clock = /^(\d{2,}):([0-5]\d):([0-5]\d)(?:\.(\d{1,3}))?$/.exec(trimmed);
1131
+ if (!clock) return false;
1132
+ return Number(clock[1]) > 0 || Number(clock[2]) > 0 || Number(clock[3]) > 0 || Number(clock[4] ?? 0) > 0;
1133
+ }
1134
+ function attr(token, name) {
1135
+ return token.attributes.get(name.toLowerCase());
1136
+ }
1137
+ var DEFAULT_LANGUAGE_ALIASES = {
1138
+ "zh-CN": ["zh-Hans"],
1139
+ "zh-TW": ["zh-Hant"]
1140
+ };
1141
+ function canonicalLanguageTag(language) {
1142
+ const trimmed = language.trim();
1143
+ if (!trimmed) return "";
1144
+ try {
1145
+ return new Intl.Locale(trimmed).toString().toLowerCase();
1146
+ } catch {
1147
+ return trimmed.toLowerCase();
1148
+ }
1149
+ }
1150
+ function createLanguageNormalizer(options) {
1151
+ const aliases = /* @__PURE__ */ new Map();
1152
+ const addAliasGroup = (canonical, values) => {
1153
+ const normalizedCanonical = canonicalLanguageTag(canonical);
1154
+ if (!normalizedCanonical) return;
1155
+ aliases.set(normalizedCanonical, normalizedCanonical);
1156
+ for (const value of values) {
1157
+ const normalizedValue = canonicalLanguageTag(value);
1158
+ if (normalizedValue) aliases.set(normalizedValue, normalizedCanonical);
1159
+ }
1160
+ };
1161
+ for (const [canonical, values] of Object.entries(DEFAULT_LANGUAGE_ALIASES)) addAliasGroup(canonical, values);
1162
+ for (const [canonical, valueOrValues] of Object.entries(options.languageAliases ?? {}))
1163
+ addAliasGroup(canonical, typeof valueOrValues === "string" ? [valueOrValues] : valueOrValues);
1164
+ return (language) => {
1165
+ const customValue = options.normalizeLanguage ? options.normalizeLanguage(language) : language;
1166
+ const normalized = canonicalLanguageTag(customValue);
1167
+ return aliases.get(normalized) ?? normalized;
1168
+ };
1169
+ }
1170
+ function voiceLocalePrefix(voiceName) {
1171
+ const match = /^(?<language>[A-Za-z]{2,3})-(?<region>[A-Za-z]{2}|\d{3})(?:-|$)/.exec(voiceName.trim());
1172
+ if (!match?.groups) return void 0;
1173
+ const tag = `${match.groups.language}-${match.groups.region}`;
1174
+ return {
1175
+ language: match.groups.language.toLowerCase(),
1176
+ region: match.groups.region.toLowerCase(),
1177
+ tag
1178
+ };
1179
+ }
1180
+ function definitionFromStyleMap(voiceName, styles) {
1181
+ return {
1182
+ name: voiceName,
1183
+ locale: voiceLocalePrefix(voiceName)?.tag ?? "",
1184
+ styles
1185
+ };
1186
+ }
1187
+ function normalizeVoiceCatalog(options) {
1188
+ const definitions = /* @__PURE__ */ new Map();
1189
+ for (const definition of AZURE_VOICE_DEFINITIONS) definitions.set(definition.name.toLowerCase(), definition);
1190
+ for (const definition of options.voiceCatalog ?? []) definitions.set(definition.name.toLowerCase(), definition);
1191
+ for (const definition of options.voiceDefinitions ?? []) definitions.set(definition.name.toLowerCase(), definition);
1192
+ for (const definition of options.customVoiceDefinitions ?? [])
1193
+ definitions.set(definition.name.toLowerCase(), definition);
1194
+ for (const [voiceName, styles] of Object.entries(options.customVoiceStyleMap ?? {})) {
1195
+ const key = voiceName.toLowerCase();
1196
+ const current = definitions.get(key);
1197
+ definitions.set(key, {
1198
+ ...current ?? definitionFromStyleMap(voiceName, styles),
1199
+ name: current?.name ?? voiceName,
1200
+ styles: styles.map((style) => style.toLowerCase())
1201
+ });
1202
+ }
1203
+ return definitions;
1204
+ }
1205
+ function diagnosticSeverity(policy) {
1206
+ if (policy === "ignore") return void 0;
1207
+ return policy === "error" ? "error" : "warning";
1208
+ }
1209
+ function languagePart(language) {
1210
+ try {
1211
+ return new Intl.Locale(language).language.toLowerCase();
1212
+ } catch {
1213
+ return language.split("-")[0]?.toLowerCase() ?? "";
1214
+ }
1215
+ }
1216
+ function definitionMatchesLanguage(definition, voiceName, language, normalizeLanguage) {
1217
+ const candidateLanguages = definition ? [definition.locale, ...definition.secondaryLocales ?? []].filter(Boolean) : [voiceLocalePrefix(voiceName)?.tag ?? ""];
1218
+ if (candidateLanguages.length === 0 || !language.trim()) return void 0;
1219
+ const normalizedLanguage = normalizeLanguage(language);
1220
+ const normalizedCandidates = candidateLanguages.map(normalizeLanguage);
1221
+ if (normalizedCandidates.includes(normalizedLanguage)) return true;
1222
+ if (!normalizedLanguage || !normalizedCandidates.some(Boolean)) return void 0;
1223
+ return normalizedLanguage === languagePart(normalizedLanguage) ? normalizedCandidates.some((candidate) => languagePart(candidate) === normalizedLanguage) : false;
1224
+ }
1225
+ function canonicalTagName(name) {
1226
+ const normalized = name.toLowerCase();
1227
+ if (normalized === "express-as" || normalized === "expressas") return "mstts:express-as";
1228
+ if (normalized === "sayas") return "say-as";
1229
+ return normalized;
1230
+ }
1231
+ function validateVoiceFeatureMatrix(token, source, diagnostics, voiceName, definition) {
1232
+ if (!voiceName || !definition || token.name.toLowerCase() === "voice" || token.name.toLowerCase() === "mstts:turn")
1233
+ return;
1234
+ const tagName = canonicalTagName(token.name);
1235
+ const unsupportedTags = new Set((definition.unsupportedTags ?? []).map(canonicalTagName));
1236
+ const supportedTags = definition.supportedTags?.map(canonicalTagName);
1237
+ if (unsupportedTags.has(tagName) || supportedTags !== void 0 && !supportedTags.includes(tagName)) {
1238
+ addDiagnostic(
1239
+ diagnostics,
1240
+ source,
1241
+ token.start,
1242
+ `Tag <${token.name}> is not supported by voice "${voiceName}" according to the configured feature matrix.`,
1243
+ "error",
1244
+ "azure-unsupported-tag-for-voice"
1245
+ );
1246
+ }
1247
+ }
1248
+ function validateAudioSource(token, source, diagnostics, options, elementName2) {
1249
+ const src = attr(token, "src");
1250
+ if (!src) {
1251
+ addDiagnostic(diagnostics, source, token.start, `<${elementName2}> requires a "src" attribute.`);
1252
+ return;
1253
+ }
1254
+ let parsed;
1255
+ try {
1256
+ parsed = new URL(src);
1257
+ } catch {
1258
+ addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must be an absolute HTTP(S) URL.`);
1259
+ return;
1260
+ }
1261
+ if (parsed.protocol !== "https:" && !(options.allowHttpAudio && parsed.protocol === "http:"))
1262
+ addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> must use HTTPS.`);
1263
+ if (options.allowedAudioOrigins && !options.allowedAudioOrigins.includes(parsed.origin))
1264
+ addDiagnostic(diagnostics, source, token.start, `<${elementName2} src> origin "${parsed.origin}" is not allowed.`);
1265
+ else if (!options.allowExternalAudio)
1266
+ addDiagnostic(
1267
+ diagnostics,
1268
+ source,
1269
+ token.start,
1270
+ `<${elementName2} src> external origin "${parsed.origin}" is blocked by default; set allowExternalAudio to true or provide allowedAudioOrigins.`
1271
+ );
1272
+ }
1273
+ function validateElement(token, source, diagnostics, voiceName, options, voiceCatalog) {
1274
+ const name = token.name.toLowerCase();
1275
+ if (name === "voice" && !attr(token, "name")?.trim())
1276
+ addDiagnostic(diagnostics, source, token.start, '<voice> requires a non-empty "name" attribute.');
1277
+ if (name === "break") {
1278
+ const time = attr(token, "time");
1279
+ const strength = attr(token, "strength");
1280
+ if (!time && !strength)
1281
+ addDiagnostic(diagnostics, source, token.start, '<break> requires either "time" or "strength".');
1282
+ if (time && strength)
1283
+ addDiagnostic(diagnostics, source, token.start, '<break> must not specify both "time" and "strength".');
1284
+ if (time && !/^\d+(?:\.\d+)?(?:ms|s)$/.test(time.trim()))
1285
+ addDiagnostic(diagnostics, source, token.start, '<break time> must use a numeric value followed by "ms" or "s".');
1286
+ if (strength && !ALLOWED_BREAK_STRENGTHS.has(strength))
1287
+ addDiagnostic(diagnostics, source, token.start, `Unsupported <break strength> value "${strength}".`);
1288
+ }
1289
+ if (name === "prosody") {
1290
+ const rate = attr(token, "rate");
1291
+ const pitch = attr(token, "pitch");
1292
+ const volume = attr(token, "volume");
1293
+ if (rate && !isSupportedProsodyRate(rate))
1294
+ addDiagnostic(diagnostics, source, token.start, `Unsupported <prosody rate> value "${rate}".`);
1295
+ if (pitch && !/^(x-low|low|medium|high|x-high|[+-]?\d+(?:\.\d+)?(?:st|Hz|%)?)$/.test(pitch.trim()))
1296
+ addDiagnostic(diagnostics, source, token.start, `Unsupported <prosody pitch> value "${pitch}".`);
1297
+ if (volume && !/^(silent|x-soft|soft|medium|loud|x-loud|[+-]?\d+(?:\.\d+)?(?:dB|%)?)$/.test(volume.trim()))
1298
+ addDiagnostic(diagnostics, source, token.start, `Unsupported <prosody volume> value "${volume}".`);
1299
+ }
1300
+ if (name === "mstts:express-as" || name === "express-as" || name === "expressas") {
1301
+ const style = attr(token, "style");
1302
+ if (!style?.trim())
1303
+ addDiagnostic(diagnostics, source, token.start, '<mstts:express-as> requires a non-empty "style" attribute.');
1304
+ const degree = attr(token, "styledegree") ?? attr(token, "style-degree");
1305
+ if (degree && (!/^\d+(?:\.\d+)?$/.test(degree) || Number(degree) < 0.01 || Number(degree) > 2))
1306
+ addDiagnostic(
1307
+ diagnostics,
1308
+ source,
1309
+ token.start,
1310
+ "<mstts:express-as styledegree> must be a number between 0.01 and 2."
1311
+ );
1312
+ const role = attr(token, "role");
1313
+ if (role && !ALLOWED_ROLES.has(role))
1314
+ addDiagnostic(diagnostics, source, token.start, `Unsupported <mstts:express-as role> value "${role}".`);
1315
+ const definition = voiceName ? voiceCatalog.get(voiceName.toLowerCase()) : void 0;
1316
+ const supportedStyles = definition?.styles;
1317
+ const severity = diagnosticSeverity(options.unsupportedStylePolicy ?? options.unknownVoicePolicy ?? "warn");
1318
+ if (style && definition && !supportedStyles?.some((candidate) => candidate.toLowerCase() === style.toLowerCase()) && severity)
1319
+ addDiagnostic(
1320
+ diagnostics,
1321
+ source,
1322
+ token.start,
1323
+ `Unknown style "${style}" is not supported by voice "${voiceName}" according to the configured voice style map.`,
1324
+ severity,
1325
+ "azure-unsupported-style"
1326
+ );
1327
+ if (style && voiceName && !definition && severity)
1328
+ addDiagnostic(
1329
+ diagnostics,
1330
+ source,
1331
+ token.start,
1332
+ `Unknown style "${style}" cannot be verified because voice "${voiceName}" is not registered in the voice style map.`,
1333
+ severity
1334
+ );
1335
+ }
1336
+ if (name === "say-as" || name === "sayas") {
1337
+ const interpretAs = attr(token, "interpret-as");
1338
+ if (!interpretAs || !ALLOWED_SAY_AS.has(interpretAs))
1339
+ addDiagnostic(diagnostics, source, token.start, `<say-as> requires a supported "interpret-as" value.`);
1340
+ }
1341
+ if (name === "phoneme" && (!attr(token, "alphabet") || !attr(token, "ph")))
1342
+ addDiagnostic(diagnostics, source, token.start, '<phoneme> requires both "alphabet" and "ph" attributes.');
1343
+ if (name === "emphasis" && attr(token, "level") && !ALLOWED_EMPHASIS_LEVELS.has(attr(token, "level") ?? ""))
1344
+ addDiagnostic(diagnostics, source, token.start, `Unsupported <emphasis level> value "${attr(token, "level")}".`);
1345
+ if (name === "sub" && !attr(token, "alias")?.trim())
1346
+ addDiagnostic(diagnostics, source, token.start, '<sub> requires a non-empty "alias" attribute.');
1347
+ if (name === "lang" && !attr(token, "xml:lang")?.trim() && !attr(token, "lang")?.trim())
1348
+ addDiagnostic(diagnostics, source, token.start, '<lang> requires an "xml:lang" attribute.');
1349
+ if (name === "mark" && !attr(token, "name")?.trim())
1350
+ addDiagnostic(diagnostics, source, token.start, '<mark> requires a non-empty "name" attribute.');
1351
+ if (name === "bookmark" && !attr(token, "mark")?.trim())
1352
+ addDiagnostic(diagnostics, source, token.start, '<bookmark> requires a non-empty "mark" attribute.');
1353
+ if (name === "lexicon") {
1354
+ const uri = attr(token, "uri");
1355
+ if (!uri) addDiagnostic(diagnostics, source, token.start, '<lexicon> requires a "uri" attribute.');
1356
+ else {
1357
+ try {
1358
+ const parsed = new URL(uri);
1359
+ if (parsed.protocol !== "https:")
1360
+ addDiagnostic(diagnostics, source, token.start, "<lexicon uri> must use HTTPS.");
1361
+ } catch {
1362
+ addDiagnostic(diagnostics, source, token.start, "<lexicon uri> must be an absolute HTTPS URL.");
1363
+ }
1364
+ }
1365
+ }
1366
+ if (name === "mstts:silence") {
1367
+ const type = attr(token, "type");
1368
+ const value = attr(token, "value");
1369
+ if (!type || !ALLOWED_SILENCE_TYPES.has(type))
1370
+ addDiagnostic(diagnostics, source, token.start, '<mstts:silence> requires a supported "type" attribute.');
1371
+ if (!value || !/^\d+(?:\.\d+)?(?:ms|s)$/.test(value.trim()))
1372
+ addDiagnostic(diagnostics, source, token.start, '<mstts:silence> requires a time-valued "value" attribute.');
1373
+ }
1374
+ if (name === "mstts:audioduration") {
1375
+ const value = attr(token, "value");
1376
+ if (!value || !isValidAzureAudioDuration(value))
1377
+ addDiagnostic(
1378
+ diagnostics,
1379
+ source,
1380
+ token.start,
1381
+ '<mstts:audioduration> requires a positive duration such as "10s", "5000ms", or "00:00:10".'
1382
+ );
1383
+ if (!token.selfClosing)
1384
+ addDiagnostic(diagnostics, source, token.start, "<mstts:audioduration> must be self-closing.");
1385
+ }
1386
+ if (name === "mstts:viseme") {
1387
+ const type = attr(token, "type");
1388
+ if (!type || !ALLOWED_VISEME_TYPES.has(type))
1389
+ addDiagnostic(diagnostics, source, token.start, '<mstts:viseme> requires a supported "type" attribute.');
1390
+ }
1391
+ if (name === "audio") {
1392
+ validateAudioSource(token, source, diagnostics, options, "audio");
1393
+ }
1394
+ if (name === "mstts:turn") {
1395
+ if (!attr(token, "voice")?.trim())
1396
+ addDiagnostic(diagnostics, source, token.start, '<mstts:turn> requires a non-empty "voice" attribute.');
1397
+ if (token.parentName?.toLowerCase() !== "mstts:dialog")
1398
+ addDiagnostic(diagnostics, source, token.start, "<mstts:turn> is only allowed directly inside <mstts:dialog>.");
1399
+ }
1400
+ if (name === "mstts:backgroundaudio") {
1401
+ validateAudioSource(token, source, diagnostics, options, "mstts:backgroundaudio");
1402
+ const volume = attr(token, "volume");
1403
+ if (volume && !/^(silent|x-soft|soft|medium|loud|x-loud|[+-]?\d+(?:\.\d+)?(?:dB|%))$/i.test(volume.trim()))
1404
+ addDiagnostic(diagnostics, source, token.start, `Unsupported <mstts:backgroundaudio volume> value "${volume}".`);
1405
+ for (const [attribute, value] of [
1406
+ ["fadein", attr(token, "fadein")],
1407
+ ["fadeout", attr(token, "fadeout")]
1408
+ ]) {
1409
+ if (value && !isValidAzureAudioDuration(value))
1410
+ addDiagnostic(
1411
+ diagnostics,
1412
+ source,
1413
+ token.start,
1414
+ `<mstts:backgroundaudio ${attribute}> must be a positive duration such as "500ms" or "10s".`
1415
+ );
1416
+ }
1417
+ if (!token.selfClosing)
1418
+ addDiagnostic(diagnostics, source, token.start, "<mstts:backgroundaudio> must be self-closing.");
1419
+ }
1420
+ }
1421
+ function validateAzureSsml(ssml, options = {}) {
1422
+ const diagnostics = [];
1423
+ if (typeof ssml !== "string") {
1424
+ return [
1425
+ {
1426
+ line: 1,
1427
+ column: 1,
1428
+ message: "SSML input must be a string",
1429
+ severity: "error",
1430
+ source: "ssml-static-validator"
1431
+ }
1432
+ ];
1433
+ }
1434
+ const maxLength = options.maxLength ?? 1e4;
1435
+ if (ssml.length > maxLength)
1436
+ addDiagnostic(diagnostics, ssml, maxLength, `SSML exceeds the maximum length of ${maxLength} characters.`);
1437
+ try {
1438
+ parseSsml(ssml);
1439
+ } catch (error) {
1440
+ const message = error instanceof Error ? error.message.replace(/ at position \d+$/, "") : String(error);
1441
+ const match = / at position (\d+)$/.exec(error instanceof Error ? error.message : "");
1442
+ addDiagnostic(diagnostics, ssml, match ? Number(match[1]) : 0, message);
1443
+ return diagnostics;
1444
+ }
1445
+ const tokens = tokenizeElements(ssml);
1446
+ const speak = tokens.find((token) => token.name.toLowerCase() === "speak");
1447
+ const voices = tokens.filter((token) => token.name.toLowerCase() === "voice");
1448
+ if (!speak || voices.length === 0)
1449
+ addDiagnostic(
1450
+ diagnostics,
1451
+ ssml,
1452
+ speak?.start ?? 0,
1453
+ "Azure SSML requires at least one <voice> element under <speak>."
1454
+ );
1455
+ const voiceName = voices[0] ? attr(voices[0], "name") : void 0;
1456
+ const voiceCatalog = normalizeVoiceCatalog(options);
1457
+ const normalizeLanguage = createLanguageNormalizer(options);
1458
+ const policySeverity = diagnosticSeverity(options.unknownVoicePolicy ?? "warn");
1459
+ const voicesToValidate = options.validateNestedVoices === false ? voices.slice(0, 1) : voices;
1460
+ for (const token of voicesToValidate) {
1461
+ const name = attr(token, "name")?.trim();
1462
+ const language = attr(token, "xml:lang")?.trim() || (speak ? attr(speak, "xml:lang")?.trim() : void 0);
1463
+ const definition = name ? voiceCatalog.get(name.toLowerCase()) : void 0;
1464
+ if (name && !definition && policySeverity)
1465
+ addDiagnostic(
1466
+ diagnostics,
1467
+ ssml,
1468
+ token.start,
1469
+ `Unknown voice "${name}" is not registered in the voice catalog.`,
1470
+ policySeverity,
1471
+ "azure-unknown-voice"
1472
+ );
1473
+ if (name && language && definitionMatchesLanguage(definition, name, language, normalizeLanguage) === false)
1474
+ addDiagnostic(
1475
+ diagnostics,
1476
+ ssml,
1477
+ token.start,
1478
+ `Voice "${name}" does not match language "${language}"; the voice name prefix indicates a different language or region.`,
1479
+ "warning",
1480
+ "azure-locale-mismatch"
1481
+ );
1482
+ }
1483
+ for (const token of tokens) {
1484
+ const tokenName = token.name.toLowerCase();
1485
+ const tokenVoiceName = tokenName === "voice" ? attr(token, "name")?.trim() : tokenName === "mstts:turn" ? attr(token, "voice")?.trim() || token.parentVoiceName : options.validateNestedVoices === false ? voiceName : token.parentVoiceName;
1486
+ validateElement(token, ssml, diagnostics, tokenVoiceName, options, voiceCatalog);
1487
+ const definition = tokenVoiceName ? voiceCatalog.get(tokenVoiceName.toLowerCase()) : void 0;
1488
+ validateVoiceFeatureMatrix(token, ssml, diagnostics, tokenVoiceName, definition);
1489
+ if (tokenName === "voice" && options.model && definition?.models && !definition.models.some((model) => model.toLowerCase() === options.model?.toLowerCase())) {
1490
+ addDiagnostic(
1491
+ diagnostics,
1492
+ ssml,
1493
+ token.start,
1494
+ `Voice "${tokenVoiceName}" does not support model "${options.model}" according to the configured feature matrix.`,
1495
+ "error",
1496
+ "azure-unsupported-model-for-voice"
1497
+ );
1498
+ }
1499
+ }
1500
+ return diagnostics;
1501
+ }
1502
+ var AZURE_VOICE_CATALOG_METADATA = {
1503
+ apiVersion: "2025-10-01",
1504
+ generatedAt: "2026-08-28T00:00:00.000Z",
1505
+ regions: [],
1506
+ voiceCount: AZURE_VOICE_DEFINITIONS.length
1507
+ };
813
1508
 
814
1509
  // packages/ssml-editor-react/src/clearSsmlDocument.ts
815
1510
  function getDocumentChildren(document2) {
@@ -1948,6 +2643,11 @@ var SSML_ATTRIBUTE_PRESETS = {
1948
2643
  "mstts:audioduration": {
1949
2644
  value: AUDIO_DURATION_PRESETS
1950
2645
  },
2646
+ "mstts:backgroundaudio": {
2647
+ volume: PROSODY_VOLUME_PRESETS,
2648
+ fadein: AUDIO_DURATION_PRESETS,
2649
+ fadeout: AUDIO_DURATION_PRESETS
2650
+ },
1951
2651
  silence: {
1952
2652
  type: SILENCE_TYPE_PRESETS,
1953
2653
  value: SILENCE_VALUE_PRESETS
@@ -2284,7 +2984,7 @@ var SILENCE_VALUE_DESCRIPTIONS = {
2284
2984
  };
2285
2985
 
2286
2986
  // packages/ssml-editor-react/src/ssmlContext.ts
2287
- function findTagEnd2(source, start, limit) {
2987
+ function findTagEnd3(source, start, limit) {
2288
2988
  let quote;
2289
2989
  for (let index = start + 1; index < limit; index += 1) {
2290
2990
  const character = source[index];
@@ -2334,7 +3034,7 @@ function findActiveSsmlTags(source, offset) {
2334
3034
  index = end;
2335
3035
  continue;
2336
3036
  }
2337
- const tagEnd = findTagEnd2(source, tagStart, source.length);
3037
+ const tagEnd = findTagEnd3(source, tagStart, source.length);
2338
3038
  const tag = source.slice(tagStart, tagEnd === -1 ? source.length : tagEnd + 1);
2339
3039
  const closingMatch = tag.match(/^<\s*\/\s*([A-Za-z_][A-Za-z0-9_.:-]*)/);
2340
3040
  const openingMatch = tag.match(/^<\s*([A-Za-z_][A-Za-z0-9_.:-]*)/);
@@ -2377,7 +3077,7 @@ function findSsmlVoiceContext(source, offset) {
2377
3077
  index = end === -1 || end + 2 > limit ? limit : end + 2;
2378
3078
  continue;
2379
3079
  }
2380
- const tagEnd = findTagEnd2(source, tagStart, limit);
3080
+ const tagEnd = findTagEnd3(source, tagStart, limit);
2381
3081
  if (tagEnd === -1) {
2382
3082
  break;
2383
3083
  }
@@ -2439,6 +3139,24 @@ var SSML_COMPLETION_SNIPPETS = [
2439
3139
  label: "mstts:audioduration",
2440
3140
  insertText: '<mstts:audioduration value="10s" />'
2441
3141
  },
3142
+ {
3143
+ label: "mstts:dialog",
3144
+ insertText: `<mstts:dialog>
3145
+ <mstts:turn voice="\${1:en-US-JennyNeural}">\${2:text}</mstts:turn>
3146
+ </mstts:dialog>`
3147
+ },
3148
+ {
3149
+ label: "mstts:turn",
3150
+ insertText: `<mstts:turn voice="\${1:en-US-JennyNeural}">\${2:text}</mstts:turn>`
3151
+ },
3152
+ {
3153
+ label: "mstts:backgroundaudio",
3154
+ insertText: `<mstts:backgroundaudio src="\${1:https://example.com/audio.mp3}" volume="\${2:-3dB}" />`
3155
+ },
3156
+ {
3157
+ label: "mstts:ttsembedding",
3158
+ insertText: `<mstts:ttsembedding>\${1:text}</mstts:ttsembedding>`
3159
+ },
2442
3160
  {
2443
3161
  label: "sub",
2444
3162
  insertText: `<sub alias="\${1:\u8AAD\u307F}">\${2:\u6F22\u5B57}</sub>`
@@ -3184,6 +3902,63 @@ var SSML_TAG_DEFINITIONS = [
3184
3902
  example: "10s"
3185
3903
  }
3186
3904
  ]
3905
+ },
3906
+ {
3907
+ name: "mstts:dialog",
3908
+ description: "Groups multiple Azure dialog turns that can use different voices.",
3909
+ parameters: []
3910
+ },
3911
+ {
3912
+ name: "mstts:turn",
3913
+ description: "Adds one dialog turn using the required Azure voice name.",
3914
+ parameters: [
3915
+ {
3916
+ name: "voice",
3917
+ description: "The Azure voice used for this turn, such as `en-US-JennyNeural`.",
3918
+ example: "en-US-JennyNeural"
3919
+ }
3920
+ ]
3921
+ },
3922
+ {
3923
+ name: "mstts:backgroundaudio",
3924
+ description: "Plays background audio while speech is synthesized.",
3925
+ parameters: [
3926
+ {
3927
+ name: "src",
3928
+ description: "An absolute HTTP(S) URL for the background audio file.",
3929
+ example: "https://example.com/music.mp3"
3930
+ },
3931
+ {
3932
+ name: "volume",
3933
+ description: "The background audio volume, for example `-3dB` or `medium`.",
3934
+ example: "-3dB"
3935
+ },
3936
+ {
3937
+ name: "fadein",
3938
+ description: "The fade-in duration, for example `1s`.",
3939
+ example: "1s"
3940
+ },
3941
+ {
3942
+ name: "fadeout",
3943
+ description: "The fade-out duration, for example `500ms`.",
3944
+ example: "500ms"
3945
+ }
3946
+ ]
3947
+ },
3948
+ {
3949
+ name: "mstts:ttsembedding",
3950
+ description: "Embeds custom voice or speaker profile metadata for Azure Speech.",
3951
+ parameters: []
3952
+ },
3953
+ {
3954
+ name: "mstts:embedding",
3955
+ description: "Specifies embedding metadata for custom voice scenarios.",
3956
+ parameters: []
3957
+ },
3958
+ {
3959
+ name: "mstts:voiceconversion",
3960
+ description: "Specifies voice conversion metadata for custom voice scenarios.",
3961
+ parameters: []
3187
3962
  }
3188
3963
  ];
3189
3964
  var definitionsByName = /* @__PURE__ */ new Map();
@@ -3242,7 +4017,7 @@ function toRange(source, token) {
3242
4017
  function containsOffset(token, offset) {
3243
4018
  return offset >= token.start && offset < token.end;
3244
4019
  }
3245
- function findTagEnd3(source, start) {
4020
+ function findTagEnd4(source, start) {
3246
4021
  let quote;
3247
4022
  for (let index = start; index < source.length; index += 1) {
3248
4023
  const character = source[index];
@@ -3373,7 +4148,7 @@ function findTagAtOffset(source, offset) {
3373
4148
  searchStart = tokenEnd2;
3374
4149
  continue;
3375
4150
  }
3376
- const tagEnd = findTagEnd3(source, start + 1);
4151
+ const tagEnd = findTagEnd4(source, start + 1);
3377
4152
  const contentEnd = tagEnd ?? source.length;
3378
4153
  const tokenEnd = tagEnd === void 0 ? source.length : tagEnd + 1;
3379
4154
  if (offset < tokenEnd) {
@@ -3989,6 +4764,60 @@ section[data-ssml-editor] {
3989
4764
  border-radius: 0.25rem;
3990
4765
  overflow: visible;
3991
4766
  }
4767
+ [data-ssml-editor] .ssml-editor-visual {
4768
+ display: grid;
4769
+ gap: 0.75rem;
4770
+ min-height: 8rem;
4771
+ padding: 0.75rem;
4772
+ border: 1px solid var(--ssml-editor-control-border);
4773
+ border-radius: 0.25rem;
4774
+ }
4775
+ [data-ssml-editor] .ssml-editor-visual-layout {
4776
+ display: grid;
4777
+ grid-template-columns: minmax(12rem, 0.35fr) minmax(16rem, 1fr);
4778
+ gap: 1rem;
4779
+ }
4780
+ [data-ssml-editor] .ssml-editor-visual-tree ul {
4781
+ margin: 0;
4782
+ padding-left: 1.25rem;
4783
+ }
4784
+ [data-ssml-editor] .ssml-editor-visual button {
4785
+ padding: 0.35rem 0.5rem;
4786
+ border: 1px solid var(--ssml-editor-control-border);
4787
+ border-radius: 0.25rem;
4788
+ color: var(--ssml-editor-color);
4789
+ background: var(--ssml-editor-control-bg);
4790
+ cursor: pointer;
4791
+ }
4792
+ [data-ssml-editor] .ssml-editor-visual button[data-selected="true"] {
4793
+ border-color: var(--ssml-editor-active-border);
4794
+ background: var(--ssml-editor-active-bg);
4795
+ }
4796
+ [data-ssml-editor] .ssml-editor-visual textarea {
4797
+ box-sizing: border-box;
4798
+ width: 100%;
4799
+ min-height: 6rem;
4800
+ padding: 0.5rem;
4801
+ color: var(--ssml-editor-color);
4802
+ background: var(--ssml-editor-control-bg);
4803
+ border: 1px solid var(--ssml-editor-control-border);
4804
+ border-radius: 0.25rem;
4805
+ font: inherit;
4806
+ }
4807
+ [data-ssml-editor] .ssml-editor-visual-actions,
4808
+ [data-ssml-editor] .ssml-editor-visual-breadcrumb {
4809
+ display: flex;
4810
+ flex-wrap: wrap;
4811
+ gap: 0.4rem;
4812
+ align-items: center;
4813
+ }
4814
+ [data-ssml-editor] .ssml-editor-visual-errors {
4815
+ padding: 0.5rem;
4816
+ color: #b91c1c;
4817
+ background: #fef2f2;
4818
+ border: 1px solid #b91c1c;
4819
+ border-radius: 0.25rem;
4820
+ }
3992
4821
  `.trim();
3993
4822
  function injectStyles() {
3994
4823
  if (typeof document === "undefined" || document.getElementById(STYLE_ID)) {
@@ -4002,6 +4831,55 @@ function injectStyles() {
4002
4831
  function isDarkTheme(theme) {
4003
4832
  return theme === "vs-dark" || theme.toLowerCase().includes("dark");
4004
4833
  }
4834
+ function isElement(node) {
4835
+ return typeof node !== "string" && node.type !== "text";
4836
+ }
4837
+ function visualElementName(element) {
4838
+ return element.type === "custom" || element.type === "element" ? element.name : element.type;
4839
+ }
4840
+ function collectVisualTextLeaves(nodes, path = [], ancestors = []) {
4841
+ return nodes.flatMap((node, index) => {
4842
+ const currentPath = [...path, index];
4843
+ if (typeof node === "string") return [{ path: currentPath, value: node, ancestors }];
4844
+ if (node.type === "text") return [{ path: currentPath, value: node.value, ancestors }];
4845
+ return collectVisualTextLeaves(node.children ?? [], currentPath, [...ancestors, visualElementName(node)]);
4846
+ });
4847
+ }
4848
+ function updateVisualNodes(nodes, path, update) {
4849
+ if (path.length === 0) return nodes;
4850
+ const [index, ...rest] = path;
4851
+ return nodes.flatMap((node, nodeIndex) => {
4852
+ if (nodeIndex !== index) return [node];
4853
+ if (rest.length === 0) {
4854
+ const next = update(node);
4855
+ return Array.isArray(next) ? next : [next];
4856
+ }
4857
+ if (!isElement(node)) return [node];
4858
+ return [{ ...node, children: updateVisualNodes(node.children ?? [], rest, update) }];
4859
+ });
4860
+ }
4861
+ function updateVisualText(document2, path, value) {
4862
+ return { ...document2, children: updateVisualNodes(document2.children ?? [], path, () => value) };
4863
+ }
4864
+ function wrapVisualText(document2, path, start, end, type, attributes) {
4865
+ return {
4866
+ ...document2,
4867
+ children: updateVisualNodes(document2.children ?? [], path, (node) => {
4868
+ const value = typeof node === "string" ? node : node.type === "text" ? node.value : "";
4869
+ if (!value || start === end) return node;
4870
+ if (type === "break") {
4871
+ return [value.slice(0, start), { type, attributes, children: [] }, value.slice(start)].filter(
4872
+ (part) => typeof part === "string" ? part.length > 0 : true
4873
+ );
4874
+ }
4875
+ const selected = value.slice(start, end);
4876
+ const wrapper = { type, attributes, children: [selected] };
4877
+ return [value.slice(0, start), wrapper, value.slice(end)].filter(
4878
+ (part) => typeof part === "string" ? part.length > 0 : true
4879
+ );
4880
+ })
4881
+ };
4882
+ }
4005
4883
  function getMenuPosition(trigger, menu) {
4006
4884
  const bounds = trigger.getBoundingClientRect();
4007
4885
  const margin = 8;
@@ -4023,6 +4901,7 @@ var SsmlEditorElement = class extends HTMLElementBase {
4023
4901
  this.toolbarActions = null;
4024
4902
  this.display = null;
4025
4903
  this.editorContainer = null;
4904
+ this.visualContainer = null;
4026
4905
  this.helpPanel = null;
4027
4906
  this.openMenu = null;
4028
4907
  this.openMenuTrigger = null;
@@ -4036,6 +4915,8 @@ var SsmlEditorElement = class extends HTMLElementBase {
4036
4915
  this.documentState = null;
4037
4916
  this.decorationsVisible = false;
4038
4917
  this.helpOpen = false;
4918
+ this.visualSelectedPath = null;
4919
+ this.visualSelection = { start: 0, end: 0 };
4039
4920
  this.handleDocumentPointerDown = (event) => {
4040
4921
  const target = event.target;
4041
4922
  if (this.openMenu && target instanceof Node && !this.openMenu.contains(target) && !this.openMenuTrigger?.contains(target)) {
@@ -4077,6 +4958,12 @@ var SsmlEditorElement = class extends HTMLElementBase {
4077
4958
  set locale(locale) {
4078
4959
  this.setAttribute("locale", locale);
4079
4960
  }
4961
+ get editMode() {
4962
+ return this.getAttribute("edit-mode") === "visual" ? "visual" : "code";
4963
+ }
4964
+ set editMode(mode) {
4965
+ this.setAttribute("edit-mode", mode);
4966
+ }
4080
4967
  prepareDocument(value) {
4081
4968
  try {
4082
4969
  this.documentState = parseSsml(value);
@@ -4119,6 +5006,7 @@ var SsmlEditorElement = class extends HTMLElementBase {
4119
5006
  }
4120
5007
  }
4121
5008
  }
5009
+ this.renderVisualEditor();
4122
5010
  return;
4123
5011
  }
4124
5012
  if (name === "theme" && this.monaco) {
@@ -4136,6 +5024,9 @@ var SsmlEditorElement = class extends HTMLElementBase {
4136
5024
  this.renderHelp();
4137
5025
  return;
4138
5026
  }
5027
+ if (name === "edit-mode") {
5028
+ this.updateEditMode();
5029
+ }
4139
5030
  if (name === "show-decorations") {
4140
5031
  this.decorationsVisible = newValue !== null;
4141
5032
  this.updateDecorations();
@@ -4160,7 +5051,9 @@ var SsmlEditorElement = class extends HTMLElementBase {
4160
5051
  display.dataset.ssmlEditorDisplay = "";
4161
5052
  const editorContainer = document.createElement("div");
4162
5053
  editorContainer.className = "ssml-editor-editor";
4163
- display.append(editorContainer);
5054
+ const visualContainer = document.createElement("div");
5055
+ visualContainer.className = "ssml-editor-visual";
5056
+ display.append(editorContainer, visualContainer);
4164
5057
  root.append(toolbar, display);
4165
5058
  this.replaceChildren(root);
4166
5059
  this.root = root;
@@ -4168,8 +5061,11 @@ var SsmlEditorElement = class extends HTMLElementBase {
4168
5061
  this.toolbarActions = toolbarActions;
4169
5062
  this.display = display;
4170
5063
  this.editorContainer = editorContainer;
5064
+ this.visualContainer = visualContainer;
4171
5065
  this.renderToolbar();
4172
5066
  this.renderHelp();
5067
+ this.updateEditMode();
5068
+ this.renderVisualEditor();
4173
5069
  }
4174
5070
  renderToolbar() {
4175
5071
  const toolbar = this.toolbar;
@@ -4213,10 +5109,10 @@ var SsmlEditorElement = class extends HTMLElementBase {
4213
5109
  for (const id of toolbarIds) {
4214
5110
  const group = groupByButtonId.get(id);
4215
5111
  if (previousGroup !== void 0 && group !== previousGroup) {
4216
- const separator = document.createElement("span");
4217
- separator.className = "ssml-editor-toolbar-separator";
4218
- separator.setAttribute("aria-hidden", "true");
4219
- toolbarActions.append(separator);
5112
+ const separator2 = document.createElement("span");
5113
+ separator2.className = "ssml-editor-toolbar-separator";
5114
+ separator2.setAttribute("aria-hidden", "true");
5115
+ toolbarActions.append(separator2);
4220
5116
  }
4221
5117
  previousGroup = group;
4222
5118
  const insertion = insertionById.get(id);
@@ -4228,8 +5124,24 @@ var SsmlEditorElement = class extends HTMLElementBase {
4228
5124
  toolbarActions.append(this.createActionButton(id));
4229
5125
  }
4230
5126
  }
5127
+ const separator = document.createElement("span");
5128
+ separator.className = "ssml-editor-toolbar-separator";
5129
+ separator.setAttribute("aria-hidden", "true");
5130
+ toolbarActions.append(separator, this.createModeButton("visual", "Visual"), this.createModeButton("code", "Code"));
4231
5131
  this.updateActiveButtons();
4232
5132
  }
5133
+ createModeButton(mode, label) {
5134
+ const button = document.createElement("button");
5135
+ button.type = "button";
5136
+ button.className = "ssml-editor-toolbar-button";
5137
+ button.dataset.ssmlEditorButton = `edit-mode-${mode}`;
5138
+ button.setAttribute("aria-pressed", String(this.editMode === mode));
5139
+ button.textContent = label;
5140
+ button.addEventListener("click", () => {
5141
+ this.editMode = mode;
5142
+ });
5143
+ return button;
5144
+ }
4233
5145
  createActionButton(id) {
4234
5146
  const copy = EDITOR_COPY[this.locale];
4235
5147
  const labels = {
@@ -4609,6 +5521,150 @@ var SsmlEditorElement = class extends HTMLElementBase {
4609
5521
  this.root.dataset.theme = isDarkTheme(this.theme) ? "dark" : "light";
4610
5522
  }
4611
5523
  }
5524
+ updateEditMode() {
5525
+ if (this.editorContainer && this.visualContainer) {
5526
+ const visual = this.editMode === "visual";
5527
+ this.editorContainer.hidden = visual;
5528
+ this.visualContainer.hidden = !visual;
5529
+ }
5530
+ for (const mode of ["visual", "code"]) {
5531
+ const button = this.toolbarActions?.querySelector(
5532
+ `[data-ssml-editor-button="edit-mode-${mode}"]`
5533
+ );
5534
+ button?.setAttribute("aria-pressed", String(this.editMode === mode));
5535
+ button?.toggleAttribute("data-active", this.editMode === mode);
5536
+ }
5537
+ this.renderVisualEditor();
5538
+ }
5539
+ renderVisualEditor() {
5540
+ const container = this.visualContainer;
5541
+ if (!container) return;
5542
+ container.replaceChildren();
5543
+ if (!this.documentState) {
5544
+ const error = document.createElement("p");
5545
+ error.className = "ssml-editor-visual-errors";
5546
+ error.textContent = "SSML syntax must be valid before visual editing is available.";
5547
+ container.append(error);
5548
+ return;
5549
+ }
5550
+ const documentState = this.documentState;
5551
+ const leaves = collectVisualTextLeaves(documentState.children ?? []);
5552
+ const selectedLeaf = leaves.find((leaf) => leaf.path.join(".") === this.visualSelectedPath?.join(".")) ?? leaves[0];
5553
+ const breadcrumb = document.createElement("div");
5554
+ breadcrumb.className = "ssml-editor-visual-breadcrumb";
5555
+ breadcrumb.textContent = `<speak>${selectedLeaf ? ` / ${selectedLeaf.ancestors.map((name) => `<${name}>`).join(" / ")}` : ""}`;
5556
+ const clear = document.createElement("button");
5557
+ clear.type = "button";
5558
+ clear.textContent = "Clear parent";
5559
+ clear.disabled = !this.visualSelectedPath;
5560
+ clear.addEventListener("click", () => {
5561
+ this.visualSelectedPath = null;
5562
+ this.renderVisualEditor();
5563
+ });
5564
+ breadcrumb.append(clear);
5565
+ container.append(breadcrumb);
5566
+ const diagnostics = validateAzureSsml(buildSsml(documentState));
5567
+ if (diagnostics.length > 0) {
5568
+ const errors = document.createElement("div");
5569
+ errors.className = "ssml-editor-visual-errors";
5570
+ errors.setAttribute("role", "alert");
5571
+ for (const diagnostic of diagnostics) {
5572
+ const message = document.createElement("div");
5573
+ message.textContent = diagnostic.message;
5574
+ errors.append(message);
5575
+ }
5576
+ container.append(errors);
5577
+ }
5578
+ const layout = document.createElement("div");
5579
+ layout.className = "ssml-editor-visual-layout";
5580
+ const tree = document.createElement("nav");
5581
+ tree.className = "ssml-editor-visual-tree";
5582
+ tree.setAttribute("aria-label", "SSML structure tree");
5583
+ tree.append(document.createTextNode("Structure"));
5584
+ const treeList = document.createElement("ul");
5585
+ const renderTree = (nodes, parent, parentPath = []) => {
5586
+ nodes.forEach((node, index) => {
5587
+ if (!isElement(node)) return;
5588
+ const path = [...parentPath, index];
5589
+ const item = document.createElement("li");
5590
+ const button = document.createElement("button");
5591
+ button.type = "button";
5592
+ button.textContent = `<${visualElementName(node)}>`;
5593
+ button.dataset.selected = String(path.join(".") === this.visualSelectedPath?.join("."));
5594
+ button.addEventListener("click", () => {
5595
+ this.visualSelectedPath = path;
5596
+ this.renderVisualEditor();
5597
+ });
5598
+ item.append(button);
5599
+ if ((node.children ?? []).some(isElement)) {
5600
+ const childList = document.createElement("ul");
5601
+ renderTree(node.children ?? [], childList, path);
5602
+ item.append(childList);
5603
+ }
5604
+ parent.append(item);
5605
+ });
5606
+ };
5607
+ renderTree(documentState.children ?? [], treeList);
5608
+ tree.append(treeList);
5609
+ layout.append(tree);
5610
+ const form = document.createElement("div");
5611
+ form.className = "ssml-editor-visual-form";
5612
+ if (selectedLeaf) {
5613
+ const label = document.createElement("label");
5614
+ label.append(document.createTextNode("Text"));
5615
+ const textarea = document.createElement("textarea");
5616
+ textarea.value = selectedLeaf.value;
5617
+ textarea.readOnly = this.readonly;
5618
+ textarea.addEventListener("select", () => {
5619
+ this.visualSelection = { start: textarea.selectionStart, end: textarea.selectionEnd };
5620
+ });
5621
+ textarea.addEventListener("input", () => {
5622
+ if (!this.readonly) this.replaceDocument(updateVisualText(documentState, selectedLeaf.path, textarea.value));
5623
+ });
5624
+ label.append(textarea);
5625
+ form.append(label);
5626
+ const actions = document.createElement("div");
5627
+ actions.className = "ssml-editor-visual-actions";
5628
+ const wrappers = [
5629
+ ["Rate", "prosody", { rate: "slow" }],
5630
+ ["Pitch", "prosody", { pitch: "high" }],
5631
+ ["Emotion", "mstts:express-as", { style: "cheerful" }],
5632
+ ["Pause", "break", { time: "500ms" }],
5633
+ ["Pronunciation", "phoneme", { alphabet: "ipa", ph: selectedLeaf.value }]
5634
+ ];
5635
+ for (const [labelText, type, attributes] of wrappers) {
5636
+ const button = document.createElement("button");
5637
+ button.type = "button";
5638
+ button.textContent = labelText;
5639
+ button.disabled = this.readonly;
5640
+ button.addEventListener("click", () => {
5641
+ const start = this.visualSelection.start === this.visualSelection.end ? 0 : this.visualSelection.start;
5642
+ const end = this.visualSelection.start === this.visualSelection.end ? selectedLeaf.value.length : this.visualSelection.end;
5643
+ this.replaceDocument(wrapVisualText(documentState, selectedLeaf.path, start, end, type, attributes));
5644
+ this.renderVisualEditor();
5645
+ });
5646
+ actions.append(button);
5647
+ }
5648
+ const preview = document.createElement("button");
5649
+ preview.type = "button";
5650
+ preview.textContent = "Preview selection";
5651
+ preview.addEventListener("click", () => {
5652
+ this.dispatchEvent(
5653
+ new CustomEvent("preview-selection", {
5654
+ detail: { ssml: buildSsml({ ...documentState, children: [selectedLeaf.value] }) },
5655
+ bubbles: true,
5656
+ composed: true
5657
+ })
5658
+ );
5659
+ });
5660
+ actions.append(preview);
5661
+ form.append(actions);
5662
+ } else {
5663
+ form.textContent = "Select an element or text node to edit it.";
5664
+ }
5665
+ layout.append(form);
5666
+ container.append(layout);
5667
+ }
4612
5668
  updateActiveButtons() {
4613
5669
  const editor = this.editor;
4614
5670
  const model = this.model;
@@ -4636,6 +5692,7 @@ var SsmlEditorElement = class extends HTMLElementBase {
4636
5692
  return;
4637
5693
  }
4638
5694
  const model = monacoModule.editor.createModel(this.prepareDocument(this.value), "xml");
5695
+ this.renderVisualEditor();
4639
5696
  const editor = monacoModule.editor.create(container, {
4640
5697
  model,
4641
5698
  theme: this.theme,
@@ -4734,7 +5791,8 @@ SsmlEditorElement.observedAttributes = [
4734
5791
  "locale",
4735
5792
  "show-toolbar",
4736
5793
  "show-toolbar-labels",
4737
- "show-decorations"
5794
+ "show-decorations",
5795
+ "edit-mode"
4738
5796
  ];
4739
5797
 
4740
5798
  // packages/ssml-editor-elements/src/index.ts