Skip to main content

Wrap Plain Narration in SSML Safely with Node.js

Updated 5 min readMarkdown

A narration script can contain an ampersand, a model name in angle brackets, or text that happens to look like a voice tag. If you interpolate that script directly into XML, the parser may reject it or treat part of the narration as markup. The handoff should keep the editor’s text separate from markup that your application controls.

This walkthrough builds a small SSML 1.1 document from plain narration in Node.js. It produces XML you can inspect before connecting a speech service. The example stops at the text handoff.

1. Give the input two fields

The input is a JSON object with language and text. This example accepts en-US, fr-FR, and ja-JP as a local configuration choice. That list only controls this utility. Check the voice and markup options offered by the speech engine you eventually connect.

Keep a plain-text field in your data model. If a separate workflow accepts author-written SSML, give it its own field and validator. Running an already-escaped string through the plain-text path deliberately escapes its ampersands again so that the original spelling survives XML parsing.

2. Validate characters before escaping markup

Save this module as build-ssml.mjs. It checks the language against fixed values and rejects a blank script. The for-of loop traverses Unicode code points, so a valid supplementary character can pass while an isolated surrogate or a disallowed XML 1.0 character fails. Carriage returns become line feeds to match the newline convention used in the output.

const languages = new Set(['en-US', 'fr-FR', 'ja-JP']);

export function buildSsml(input) {
  const { language, text } = input ?? {};
  if (!languages.has(language)) {
    throw new RangeError('Choose en-US, fr-FR, or ja-JP');
  }
  if (typeof text !== 'string' || text.trim().length === 0) {
    throw new TypeError('Expected nonblank narration text');
  }

  const narration = text.replace(/\r\n?/g, '\n');
  for (const character of narration) {
    const cp = character.codePointAt(0);
    const legal = cp === 0x9 || cp === 0xa ||
      (cp >= 0x20 && cp <= 0xd7ff) ||
      (cp >= 0xe000 && cp <= 0xfffd) ||
      (cp >= 0x10000 && cp <= 0x10ffff);
    if (!legal) {
      throw new RangeError('Illegal XML 1.0 character U+' + cp.toString(16).toUpperCase());
    }
  }

  const escaped = narration.replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;');

  return '<?xml version="1.0" encoding="UTF-8"?>\n' +
    '<speak version="1.1" xmlns="http://www.w3.org/2001/10/synthesis" ' +
    'xml:lang="' + language + '">' + escaped + '</speak>\n';
}

Escape ampersands first so the replacements for angle brackets are applied only once. Escaping the greater-than sign also keeps the sequence of two closing brackets followed by a greater-than sign from appearing literally inside XML character data. Quotes can remain in element text. The only dynamic attribute value comes from the fixed language set.

The return value adds the SSML namespace, a version, and the language on the speak element. The body contains text only. Keep engine-specific tags in a separate layer that takes explicit structured options.

3. Add a file-to-stdout CLI

Save this second file as narration-ssml.mjs beside the module. It decodes UTF-8 strictly, parses JSON, and writes the generated document to stdout. A failed read or invalid input sets exit code 1 and writes the diagnostic to stderr. Calling it with the wrong number of arguments returns 2.

import { readFileSync } from 'node:fs';
import { buildSsml } from './build-ssml.mjs';

const args = process.argv.slice(2);
if (args.length !== 1) {
  console.error('Usage: node narration-ssml.mjs input.json');
  process.exitCode = 2;
} else {
  try {
    const bytes = readFileSync(args[0]);
    const json = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
    process.stdout.write(buildSsml(JSON.parse(json)));
  } catch (error) {
    console.error(error.message);
    process.exitCode = 1;
  }
}

Run the CLI on local narration files. It reads the entire file into memory, so add a byte limit at your application boundary if you accept uploads or large jobs. Before redirecting stdout into a release artifact, check the exit status or use a temporary output path and rename it after success.

4. Check a small narration fixture

Save this sample as narration.json. The ampersand, brackets, accents, and emoji give you several ways to verify that a parser recovers the original text. An engine may pronounce or omit symbols according to its own behavior.

{
  "language": "en-US",
  "text": "R&D says: model <A> is ready. Café demo 🚲."
}

Run node narration-ssml.mjs narration.json. The output should be the document below. The visible entity references are part of XML serialization. An XML parser resolves them back to the script’s original characters.

<?xml version="1.0" encoding="UTF-8"?>
<speak version="1.1" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="en-US">R&amp;D says: model &lt;A&gt; is ready. Café demo 🚲.</speak>

5. Check the parsed document before adding a voice

Open the result with an XML parser. Check the root name and namespace, the xml:lang value, and the text returned by the parser. A string such as a literal opening voice tag should return as text and produce no child elements. Test a string that already contains an entity spelling, plus a line ending and a supplementary Unicode character.

This example was run on Node.js 24.20.0. There were 31 build and CLI cases, including invalid Unicode, invalid UTF-8, incorrect input, and exit codes 0, 1, and 2. A separate .NET XML parser checked 12 text round trips, including language and namespace attributes and the absence of child markup.

6. Keep delivery checks separate from serialization

Once the document parses as intended, check which SSML elements and versions your chosen provider accepts. If the provider expects plain text, hand it the original script through its text field. For an SSML integration, test a short sample with the chosen voice and listen for names, abbreviations, and numbers before saving an audio deliverable.

This walkthrough used synthetic text and XML checks. It did not call a speech service, generate audio, or test pronunciation or MP3 output.

This tutorial was prepared with AI assistance. Its source is PixMind’s text-to-speech preparation guide. The markup and character rules are described in the W3C SSML 1.1 recommendation and the XML 1.0 recommendation.

About the author

  • The PixMind team shares practical AI image and video workflows. PixMind brings text-to-image, reference-based creation, editing, and video tools into one workspace. Website: https://www.pixmind.io/

No comments yet. Be the first to comment.

More from this blog