ssml-builder-js 2.5.0 → 2.7.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/core.d.mts CHANGED
@@ -172,23 +172,45 @@ declare function extractSsmlText(ssml: string): string[];
172
172
  declare function mapSsmlTextNodes(ssml: string, transform: (text: string, context: SsmlTextNodeContext) => string | Promise<string>, options?: MapSsmlTextNodesOptions): Promise<string>;
173
173
 
174
174
  type SsmlDiagnosticSeverity = "error" | "warning";
175
+ type AzureDiagnosticCode = "azure-unknown-voice" | "azure-unsupported-style" | "azure-locale-mismatch";
175
176
  interface SsmlDiagnostic {
177
+ code?: AzureDiagnosticCode;
176
178
  line: number;
177
179
  column: number;
178
180
  message: string;
179
181
  severity: SsmlDiagnosticSeverity;
180
182
  }
183
+ interface AzureVoiceDefinition {
184
+ name: string;
185
+ locale: string;
186
+ secondaryLocales?: readonly string[];
187
+ styles?: readonly string[];
188
+ }
189
+ /** Alias retained for consumers that prefer the metadata terminology. */
190
+ interface AzureVoiceMetadata extends AzureVoiceDefinition {
191
+ }
181
192
  interface AzureValidationOptions {
182
193
  allowedAudioOrigins?: readonly string[];
183
194
  allowExternalAudio?: boolean;
184
195
  allowHttpAudio?: boolean;
185
196
  customVoiceStyleMap?: Record<string, readonly string[]>;
197
+ customVoiceDefinitions?: readonly AzureVoiceDefinition[];
198
+ languageAliases?: Record<string, string | readonly string[]>;
186
199
  maxLength?: number;
200
+ normalizeLanguage?: (lang: string) => string;
187
201
  unknownVoicePolicy?: "error" | "warn" | "ignore";
202
+ unsupportedStylePolicy?: "error" | "warn" | "ignore";
188
203
  validateNestedVoices?: boolean;
204
+ voiceCatalog?: readonly AzureVoiceDefinition[];
205
+ voiceDefinitions?: readonly AzureVoiceDefinition[];
189
206
  }
207
+ type AzureLanguageNormalizationOptions = Pick<AzureValidationOptions, "languageAliases" | "normalizeLanguage">;
190
208
  /** @deprecated Use AzureValidationOptions instead. */
191
209
  type AzureSsmlValidationOptions = AzureValidationOptions;
210
+ /** Normalizes an Azure language tag using BCP 47 and the configured aliases. */
211
+ declare function normalizeAzureLanguage(language: string, options?: AzureLanguageNormalizationOptions): string;
212
+ /** Compares two Azure language tags after BCP 47 and alias normalization. */
213
+ declare function areAzureLanguagesEquivalent(first: string, second: string, options?: AzureLanguageNormalizationOptions): boolean;
192
214
  declare function validateAzureSsml(ssml: string, options?: AzureValidationOptions): SsmlDiagnostic[];
193
215
 
194
- export { type AudioElement, type AzureSsmlValidationOptions, type AzureValidationOptions, type BookmarkElement, type BreakElement, type BuildPartialSsmlOptions, type CustomElement, type EmphasisElement, type ExpressAsElement, type LangElement, type LexiconElement, type MapSsmlTextNodesOptions, type MarkElement, type MsttsSilenceElement, type MsttsVisemeElement, type NamedElement, type ParagraphElement, type PhonemeElement, type ProsodyElement, type SayAsElement, type SentenceElement, type SsmlAttributeValue, type SsmlAttributes, type SsmlBreakElement, type SsmlDiagnostic, type SsmlDiagnosticSeverity, type SsmlDocument, type SsmlElement, type SsmlElementBase, type SsmlExpressAsElement, type SsmlNode, type SsmlPartialContext, type SsmlPartialProsody, type SsmlPartialVoice, type SsmlPhonemeElement, type SsmlProsodyElement, type SsmlSayAsElement, type SsmlText, type SsmlTextNodeContext, type SsmlValidationError, type SsmlVoiceElement, type SubElement, type VoiceElement, type WordElement, buildPartialSsml, buildSsml, extractSsmlText, mapSsmlTextNodes, parseSsml, validateAzureSsml, validateSsml };
216
+ export { type AudioElement, type AzureDiagnosticCode, type AzureLanguageNormalizationOptions, type AzureSsmlValidationOptions, type AzureValidationOptions, type AzureVoiceDefinition, type AzureVoiceMetadata, type BookmarkElement, type BreakElement, type BuildPartialSsmlOptions, type CustomElement, type EmphasisElement, type ExpressAsElement, type LangElement, type LexiconElement, type MapSsmlTextNodesOptions, type MarkElement, type MsttsSilenceElement, type MsttsVisemeElement, type NamedElement, type ParagraphElement, type PhonemeElement, type ProsodyElement, type SayAsElement, type SentenceElement, type SsmlAttributeValue, type SsmlAttributes, type SsmlBreakElement, type SsmlDiagnostic, type SsmlDiagnosticSeverity, type SsmlDocument, type SsmlElement, type SsmlElementBase, type SsmlExpressAsElement, type SsmlNode, type SsmlPartialContext, type SsmlPartialProsody, type SsmlPartialVoice, type SsmlPhonemeElement, type SsmlProsodyElement, type SsmlSayAsElement, type SsmlText, type SsmlTextNodeContext, type SsmlValidationError, type SsmlVoiceElement, type SubElement, type VoiceElement, type WordElement, areAzureLanguagesEquivalent, buildPartialSsml, buildSsml, extractSsmlText, mapSsmlTextNodes, normalizeAzureLanguage, parseSsml, validateAzureSsml, validateSsml };
package/dist/core.d.ts CHANGED
@@ -172,23 +172,45 @@ declare function extractSsmlText(ssml: string): string[];
172
172
  declare function mapSsmlTextNodes(ssml: string, transform: (text: string, context: SsmlTextNodeContext) => string | Promise<string>, options?: MapSsmlTextNodesOptions): Promise<string>;
173
173
 
174
174
  type SsmlDiagnosticSeverity = "error" | "warning";
175
+ type AzureDiagnosticCode = "azure-unknown-voice" | "azure-unsupported-style" | "azure-locale-mismatch";
175
176
  interface SsmlDiagnostic {
177
+ code?: AzureDiagnosticCode;
176
178
  line: number;
177
179
  column: number;
178
180
  message: string;
179
181
  severity: SsmlDiagnosticSeverity;
180
182
  }
183
+ interface AzureVoiceDefinition {
184
+ name: string;
185
+ locale: string;
186
+ secondaryLocales?: readonly string[];
187
+ styles?: readonly string[];
188
+ }
189
+ /** Alias retained for consumers that prefer the metadata terminology. */
190
+ interface AzureVoiceMetadata extends AzureVoiceDefinition {
191
+ }
181
192
  interface AzureValidationOptions {
182
193
  allowedAudioOrigins?: readonly string[];
183
194
  allowExternalAudio?: boolean;
184
195
  allowHttpAudio?: boolean;
185
196
  customVoiceStyleMap?: Record<string, readonly string[]>;
197
+ customVoiceDefinitions?: readonly AzureVoiceDefinition[];
198
+ languageAliases?: Record<string, string | readonly string[]>;
186
199
  maxLength?: number;
200
+ normalizeLanguage?: (lang: string) => string;
187
201
  unknownVoicePolicy?: "error" | "warn" | "ignore";
202
+ unsupportedStylePolicy?: "error" | "warn" | "ignore";
188
203
  validateNestedVoices?: boolean;
204
+ voiceCatalog?: readonly AzureVoiceDefinition[];
205
+ voiceDefinitions?: readonly AzureVoiceDefinition[];
189
206
  }
207
+ type AzureLanguageNormalizationOptions = Pick<AzureValidationOptions, "languageAliases" | "normalizeLanguage">;
190
208
  /** @deprecated Use AzureValidationOptions instead. */
191
209
  type AzureSsmlValidationOptions = AzureValidationOptions;
210
+ /** Normalizes an Azure language tag using BCP 47 and the configured aliases. */
211
+ declare function normalizeAzureLanguage(language: string, options?: AzureLanguageNormalizationOptions): string;
212
+ /** Compares two Azure language tags after BCP 47 and alias normalization. */
213
+ declare function areAzureLanguagesEquivalent(first: string, second: string, options?: AzureLanguageNormalizationOptions): boolean;
192
214
  declare function validateAzureSsml(ssml: string, options?: AzureValidationOptions): SsmlDiagnostic[];
193
215
 
194
- export { type AudioElement, type AzureSsmlValidationOptions, type AzureValidationOptions, type BookmarkElement, type BreakElement, type BuildPartialSsmlOptions, type CustomElement, type EmphasisElement, type ExpressAsElement, type LangElement, type LexiconElement, type MapSsmlTextNodesOptions, type MarkElement, type MsttsSilenceElement, type MsttsVisemeElement, type NamedElement, type ParagraphElement, type PhonemeElement, type ProsodyElement, type SayAsElement, type SentenceElement, type SsmlAttributeValue, type SsmlAttributes, type SsmlBreakElement, type SsmlDiagnostic, type SsmlDiagnosticSeverity, type SsmlDocument, type SsmlElement, type SsmlElementBase, type SsmlExpressAsElement, type SsmlNode, type SsmlPartialContext, type SsmlPartialProsody, type SsmlPartialVoice, type SsmlPhonemeElement, type SsmlProsodyElement, type SsmlSayAsElement, type SsmlText, type SsmlTextNodeContext, type SsmlValidationError, type SsmlVoiceElement, type SubElement, type VoiceElement, type WordElement, buildPartialSsml, buildSsml, extractSsmlText, mapSsmlTextNodes, parseSsml, validateAzureSsml, validateSsml };
216
+ export { type AudioElement, type AzureDiagnosticCode, type AzureLanguageNormalizationOptions, type AzureSsmlValidationOptions, type AzureValidationOptions, type AzureVoiceDefinition, type AzureVoiceMetadata, type BookmarkElement, type BreakElement, type BuildPartialSsmlOptions, type CustomElement, type EmphasisElement, type ExpressAsElement, type LangElement, type LexiconElement, type MapSsmlTextNodesOptions, type MarkElement, type MsttsSilenceElement, type MsttsVisemeElement, type NamedElement, type ParagraphElement, type PhonemeElement, type ProsodyElement, type SayAsElement, type SentenceElement, type SsmlAttributeValue, type SsmlAttributes, type SsmlBreakElement, type SsmlDiagnostic, type SsmlDiagnosticSeverity, type SsmlDocument, type SsmlElement, type SsmlElementBase, type SsmlExpressAsElement, type SsmlNode, type SsmlPartialContext, type SsmlPartialProsody, type SsmlPartialVoice, type SsmlPhonemeElement, type SsmlProsodyElement, type SsmlSayAsElement, type SsmlText, type SsmlTextNodeContext, type SsmlValidationError, type SsmlVoiceElement, type SubElement, type VoiceElement, type WordElement, areAzureLanguagesEquivalent, buildPartialSsml, buildSsml, extractSsmlText, mapSsmlTextNodes, normalizeAzureLanguage, parseSsml, validateAzureSsml, validateSsml };
package/dist/core.js CHANGED
@@ -27,10 +27,12 @@ var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "
27
27
  // src/core.ts
28
28
  var core_exports = {};
29
29
  __export(core_exports, {
30
+ areAzureLanguagesEquivalent: () => areAzureLanguagesEquivalent,
30
31
  buildPartialSsml: () => buildPartialSsml,
31
32
  buildSsml: () => buildSsml,
32
33
  extractSsmlText: () => extractSsmlText,
33
34
  mapSsmlTextNodes: () => mapSsmlTextNodes,
35
+ normalizeAzureLanguage: () => normalizeAzureLanguage,
34
36
  parseSsml: () => parseSsml,
35
37
  validateAzureSsml: () => validateAzureSsml,
36
38
  validateSsml: () => validateSsml
@@ -1227,29 +1229,123 @@ function location(source, offset) {
1227
1229
  const line = before.split("\n").length;
1228
1230
  return { line, column: before.length - (before.lastIndexOf("\n") + 1) + 1 };
1229
1231
  }
1230
- function addDiagnostic(diagnostics, source, offset, message, severity = "error") {
1231
- diagnostics.push({ ...location(source, offset), message, severity });
1232
+ function addDiagnostic(diagnostics, source, offset, message, severity = "error", code) {
1233
+ diagnostics.push({ ...location(source, offset), message, severity, ...code ? { code } : {} });
1232
1234
  }
1233
1235
  function attr(token, name) {
1234
1236
  return token.attributes.get(name.toLowerCase());
1235
1237
  }
1236
- function normalizeVoiceStyleMap(customVoiceStyleMap) {
1237
- const map = new Map(
1238
- Object.entries(EXPRESS_AS_STYLES).map(([voiceName, styles]) => [voiceName.toLowerCase(), styles])
1239
- );
1240
- for (const [voiceName, styles] of Object.entries(customVoiceStyleMap ?? {})) {
1241
- map.set(
1242
- voiceName.toLowerCase(),
1243
- styles.map((style) => style.toLowerCase())
1244
- );
1238
+ var ADDITIONAL_VOICE_DEFINITIONS = [
1239
+ { name: "zh-TW-HsiaoChenNeural", locale: "zh-TW" },
1240
+ { name: "es-ES-ElviraNeural", locale: "es-ES" },
1241
+ { name: "th-TH-PremwadeeNeural", locale: "th-TH" },
1242
+ { name: "fil-PH-AngeloNeural", locale: "fil-PH" },
1243
+ { name: "vi-VN-HoaiMyNeural", locale: "vi-VN" },
1244
+ { name: "id-ID-GadisNeural", locale: "id-ID" },
1245
+ { name: "ms-MY-YasminNeural", locale: "ms-MY" }
1246
+ ];
1247
+ var DEFAULT_LANGUAGE_ALIASES = {
1248
+ "zh-CN": ["zh-Hans"],
1249
+ "zh-TW": ["zh-Hant"]
1250
+ };
1251
+ function canonicalLanguageTag(language) {
1252
+ const trimmed = language.trim();
1253
+ if (!trimmed) return "";
1254
+ try {
1255
+ return new Intl.Locale(trimmed).toString().toLowerCase();
1256
+ } catch {
1257
+ return trimmed.toLowerCase();
1245
1258
  }
1246
- return map;
1259
+ }
1260
+ function createLanguageNormalizer(options) {
1261
+ const aliases = /* @__PURE__ */ new Map();
1262
+ const addAliasGroup = (canonical, values) => {
1263
+ const normalizedCanonical = canonicalLanguageTag(canonical);
1264
+ if (!normalizedCanonical) return;
1265
+ aliases.set(normalizedCanonical, normalizedCanonical);
1266
+ for (const value of values) {
1267
+ const normalizedValue = canonicalLanguageTag(value);
1268
+ if (normalizedValue) aliases.set(normalizedValue, normalizedCanonical);
1269
+ }
1270
+ };
1271
+ for (const [canonical, values] of Object.entries(DEFAULT_LANGUAGE_ALIASES)) addAliasGroup(canonical, values);
1272
+ for (const [canonical, valueOrValues] of Object.entries(options.languageAliases ?? {}))
1273
+ addAliasGroup(canonical, typeof valueOrValues === "string" ? [valueOrValues] : valueOrValues);
1274
+ return (language) => {
1275
+ const customValue = options.normalizeLanguage ? options.normalizeLanguage(language) : language;
1276
+ const normalized = canonicalLanguageTag(customValue);
1277
+ return aliases.get(normalized) ?? normalized;
1278
+ };
1279
+ }
1280
+ function normalizeAzureLanguage(language, options = {}) {
1281
+ return createLanguageNormalizer(options)(language);
1282
+ }
1283
+ function areAzureLanguagesEquivalent(first, second, options = {}) {
1284
+ const normalize = createLanguageNormalizer(options);
1285
+ const normalizedFirst = normalize(first);
1286
+ const normalizedSecond = normalize(second);
1287
+ if (normalizedFirst === normalizedSecond) return true;
1288
+ return normalizedFirst === languagePart(normalizedFirst) && languagePart(normalizedFirst) === languagePart(normalizedSecond);
1289
+ }
1290
+ function voiceLocalePrefix(voiceName) {
1291
+ const match = /^(?<language>[A-Za-z]{2,3})-(?<region>[A-Za-z]{2}|\d{3})(?:-|$)/.exec(voiceName.trim());
1292
+ if (!match?.groups) return void 0;
1293
+ const tag = `${match.groups.language}-${match.groups.region}`;
1294
+ return {
1295
+ language: match.groups.language.toLowerCase(),
1296
+ region: match.groups.region.toLowerCase(),
1297
+ tag
1298
+ };
1299
+ }
1300
+ function definitionFromStyleMap(voiceName, styles) {
1301
+ return {
1302
+ name: voiceName,
1303
+ locale: voiceLocalePrefix(voiceName)?.tag ?? "",
1304
+ styles
1305
+ };
1306
+ }
1307
+ function normalizeVoiceCatalog(options) {
1308
+ const definitions = /* @__PURE__ */ new Map();
1309
+ for (const [name, styles] of Object.entries(EXPRESS_AS_STYLES)) {
1310
+ definitions.set(name.toLowerCase(), definitionFromStyleMap(name, styles));
1311
+ }
1312
+ for (const definition of ADDITIONAL_VOICE_DEFINITIONS) definitions.set(definition.name.toLowerCase(), definition);
1313
+ for (const definition of options.voiceCatalog ?? []) definitions.set(definition.name.toLowerCase(), definition);
1314
+ for (const definition of options.voiceDefinitions ?? []) definitions.set(definition.name.toLowerCase(), definition);
1315
+ for (const definition of options.customVoiceDefinitions ?? [])
1316
+ definitions.set(definition.name.toLowerCase(), definition);
1317
+ for (const [voiceName, styles] of Object.entries(options.customVoiceStyleMap ?? {})) {
1318
+ const key = voiceName.toLowerCase();
1319
+ const current = definitions.get(key);
1320
+ definitions.set(key, {
1321
+ ...current ?? definitionFromStyleMap(voiceName, styles),
1322
+ name: current?.name ?? voiceName,
1323
+ styles: styles.map((style) => style.toLowerCase())
1324
+ });
1325
+ }
1326
+ return definitions;
1247
1327
  }
1248
1328
  function diagnosticSeverity(policy) {
1249
1329
  if (policy === "ignore") return void 0;
1250
1330
  return policy === "error" ? "error" : "warning";
1251
1331
  }
1252
- function validateElement(token, source, diagnostics, voiceName, options, voiceStyleMap) {
1332
+ function languagePart(language) {
1333
+ try {
1334
+ return new Intl.Locale(language).language.toLowerCase();
1335
+ } catch {
1336
+ return language.split("-")[0]?.toLowerCase() ?? "";
1337
+ }
1338
+ }
1339
+ function definitionMatchesLanguage(definition, voiceName, language, normalizeLanguage) {
1340
+ const candidateLanguages = definition ? [definition.locale, ...definition.secondaryLocales ?? []].filter(Boolean) : [voiceLocalePrefix(voiceName)?.tag ?? ""];
1341
+ if (candidateLanguages.length === 0 || !language.trim()) return void 0;
1342
+ const normalizedLanguage = normalizeLanguage(language);
1343
+ const normalizedCandidates = candidateLanguages.map(normalizeLanguage);
1344
+ if (normalizedCandidates.includes(normalizedLanguage)) return true;
1345
+ if (!normalizedLanguage || !normalizedCandidates.some(Boolean)) return void 0;
1346
+ return normalizedLanguage === languagePart(normalizedLanguage) ? normalizedCandidates.some((candidate) => languagePart(candidate) === normalizedLanguage) : false;
1347
+ }
1348
+ function validateElement(token, source, diagnostics, voiceName, options, voiceCatalog) {
1253
1349
  const name = token.name.toLowerCase();
1254
1350
  if (name === "voice" && !attr(token, "name")?.trim())
1255
1351
  addDiagnostic(diagnostics, source, token.start, '<voice> requires a non-empty "name" attribute.');
@@ -1291,17 +1387,19 @@ function validateElement(token, source, diagnostics, voiceName, options, voiceSt
1291
1387
  const role = attr(token, "role");
1292
1388
  if (role && !ALLOWED_ROLES.has(role))
1293
1389
  addDiagnostic(diagnostics, source, token.start, `Unsupported <mstts:express-as role> value "${role}".`);
1294
- const supportedStyles = voiceName ? voiceStyleMap.get(voiceName.toLowerCase()) : void 0;
1295
- const severity = diagnosticSeverity(options.unknownVoicePolicy ?? "warn");
1296
- if (style && supportedStyles && !supportedStyles.includes(style.toLowerCase()) && severity)
1390
+ const definition = voiceName ? voiceCatalog.get(voiceName.toLowerCase()) : void 0;
1391
+ const supportedStyles = definition?.styles;
1392
+ const severity = diagnosticSeverity(options.unsupportedStylePolicy ?? options.unknownVoicePolicy ?? "warn");
1393
+ if (style && definition && !supportedStyles?.some((candidate) => candidate.toLowerCase() === style.toLowerCase()) && severity)
1297
1394
  addDiagnostic(
1298
1395
  diagnostics,
1299
1396
  source,
1300
1397
  token.start,
1301
1398
  `Unknown style "${style}" is not supported by voice "${voiceName}" according to the configured voice style map.`,
1302
- severity
1399
+ severity,
1400
+ "azure-unsupported-style"
1303
1401
  );
1304
- if (style && voiceName && !supportedStyles && severity)
1402
+ if (style && voiceName && !definition && severity)
1305
1403
  addDiagnostic(
1306
1404
  diagnostics,
1307
1405
  source,
@@ -1406,32 +1504,47 @@ function validateAzureSsml(ssml, options = {}) {
1406
1504
  "Azure SSML requires at least one <voice> element under <speak>."
1407
1505
  );
1408
1506
  const voiceName = voices[0] ? attr(voices[0], "name") : void 0;
1409
- const voiceStyleMap = normalizeVoiceStyleMap(options.customVoiceStyleMap);
1507
+ const voiceCatalog = normalizeVoiceCatalog(options);
1508
+ const normalizeLanguage = createLanguageNormalizer(options);
1410
1509
  const policySeverity = diagnosticSeverity(options.unknownVoicePolicy ?? "warn");
1411
1510
  const voicesToValidate = options.validateNestedVoices === false ? voices.slice(0, 1) : voices;
1412
1511
  for (const token of voicesToValidate) {
1413
1512
  const name = attr(token, "name")?.trim();
1414
- if (name && !voiceStyleMap.has(name.toLowerCase()) && policySeverity)
1513
+ const language = attr(token, "xml:lang")?.trim() || (speak ? attr(speak, "xml:lang")?.trim() : void 0);
1514
+ const definition = name ? voiceCatalog.get(name.toLowerCase()) : void 0;
1515
+ if (name && !definition && policySeverity)
1516
+ addDiagnostic(
1517
+ diagnostics,
1518
+ ssml,
1519
+ token.start,
1520
+ `Unknown voice "${name}" is not registered in the voice catalog.`,
1521
+ policySeverity,
1522
+ "azure-unknown-voice"
1523
+ );
1524
+ if (name && language && definitionMatchesLanguage(definition, name, language, normalizeLanguage) === false)
1415
1525
  addDiagnostic(
1416
1526
  diagnostics,
1417
1527
  ssml,
1418
1528
  token.start,
1419
- `Unknown voice "${name}" is not registered in the voice style map.`,
1420
- policySeverity
1529
+ `Voice "${name}" does not match language "${language}"; the voice name prefix indicates a different language or region.`,
1530
+ "warning",
1531
+ "azure-locale-mismatch"
1421
1532
  );
1422
1533
  }
1423
1534
  for (const token of tokens) {
1424
1535
  const tokenVoiceName = options.validateNestedVoices === false ? voiceName : token.parentVoiceName;
1425
- validateElement(token, ssml, diagnostics, tokenVoiceName, options, voiceStyleMap);
1536
+ validateElement(token, ssml, diagnostics, tokenVoiceName, options, voiceCatalog);
1426
1537
  }
1427
1538
  return diagnostics;
1428
1539
  }
1429
1540
  // Annotate the CommonJS export names for ESM import in node:
1430
1541
  0 && (module.exports = {
1542
+ areAzureLanguagesEquivalent,
1431
1543
  buildPartialSsml,
1432
1544
  buildSsml,
1433
1545
  extractSsmlText,
1434
1546
  mapSsmlTextNodes,
1547
+ normalizeAzureLanguage,
1435
1548
  parseSsml,
1436
1549
  validateAzureSsml,
1437
1550
  validateSsml