Skip to content

SSML input

Drive lip-sync from the same SSML you feed your TTS (issue #52). Game and VO pipelines already author their TTS input as SSML — the W3C markup Azure, Google and Amazon Polly consume — and OpenFaceFX's text tags were modelled on it. parse_ssml is a thin front-end over those #7 tags, not a new animation path: it parses an SSML document with the stdlib xml.etree.ElementTree and returns the same (clean_text, tags) that parse_tagged_transcript yields, so the unchanged naive pipeline lip-syncs it identically — breaks land as pauses, emphasis as stronger articulation, marks as events.

Enable it with the naive --ssml flag, or just pass a document with a <speak> root (auto-detected):

python -m openfacefx naive --ssml --duration 3 -o out.track.json \
  --text '<speak>Say <emphasis level="strong">brave</emphasis> <break time="300ms"/> new world <mark name="beat"/></speak>'

Each construct maps onto an existing text-tag primitive — nothing new is invented:

SSML Maps to Notes
<break time="500ms"/> / strength [pause:S] time (ms/s) wins over strength; the strength table defaults a bare <break/> to medium (0.5 s)
<emphasis level="strong">…</emphasis> [emphasis strength=..] level → dominance strength (strong 1.0, moderate 0.5, reduced/none 0); a level-less <emphasis> uses the #7 default
<sub alias="World Health Organization">WHO</sub> substituted text the spoken alias replaces the written form for G2P
<mark name="hit"/> [mark name=hit] a named phrase marker event
<p> / <s> [phrase] a phrase boundary at the element start
<say-as interpret-as=…>…</say-as> qa.normalize_transcript the enclosed text is folded to ASCII (the #23 pass)

Deferred / out of scope: <phoneme alphabet="ipa" ph="…"> pronunciation override belongs to the multi-language framework (#8) + ipa.py — its text is passed through unchanged here, the ph ignored. Any unknown element degrades to its text content and never crashes; malformed XML raises a clear ValueError at the boundary (a DOCTYPE is rejected outright — SSML has none, and it can smuggle entity-expansion).

Because it produces the same (clean_text, tags) pair as the bracket front-end, an SSML document is byte-identical to the equivalent tagged transcript through the whole pipeline, and a <speak> with no constructs is byte-identical to plain naive --text. The parse is deterministic and stdlib-only (xml.etree / re); numpy is never imported. Library callers get parse_ssml, looks_like_ssml, and the extensible EMPHASIS_STRENGTH / BREAK_STRENGTH tables.

openfacefx.ssml

SSML input adapter (issue #52): drive lip-sync from the same SSML you feed TTS.

A thin front-end over the issue-#7 text-tag system -- not a new animation path. Game / VO pipelines already author their TTS input as SSML (the W3C Speech Synthesis Markup Language that Azure, Google and Amazon Polly consume), and OpenFaceFX's text tags were deliberately modelled on it. :func:parse_ssml parses an SSML document with the stdlib xml.etree.ElementTree and returns the same (clean_text, List[Tag]) the bracket-tag front-end (:func:openfacefx.texttags.parse_tagged_transcript) yields, so the unchanged naive pipeline lip-syncs it identically. Every supported construct maps onto an existing #7 primitive -- nothing new is invented:

  • <break time="500ms"/> / strength -> a pause :class:~openfacefx. texttags.Tag (the [pause:S] path); time (ms/s) wins over strength.
  • <emphasis level="strong">...</emphasis> -> an emphasis Tag, level mapped to an issue-#18 dominance strength (see :data:EMPHASIS_STRENGTH); a level-less <emphasis> carries no strength, so the #7 default applies.
  • <sub alias="World Health Organization">WHO</sub> -> the spoken alias is substituted into clean_text for G2P.
  • <mark name="hit"/> -> a named phrase (marker) Tag; <p> / <s> -> a phrase boundary at the element start.
  • <say-as interpret-as=...>...</say-as> -> the enclosed text is routed through :func:openfacefx.qa.normalize_transcript (the #23 ASCII fold).

Deferred / out of scope: <phoneme alphabet="ipa" ph="..."> pronunciation override belongs to the multi-language framework (#8) + ipa.py -- its text is passed through here and the ph ignored. Any unknown element degrades to its text content and never crashes; malformed XML raises a clear ValueError at the boundary. Deterministic and stdlib-only (xml.etree / re); numpy is never imported, so <speak>hello world</speak> with no constructs yields ("hello world", []) -- byte-identical to the plain naive path.

EMPHASIS_STRENGTH: Dict[str, str] = {'strong': '1.0', 'moderate': '0.5', 'reduced': '0.0', 'none': '0.0'} module-attribute

BREAK_STRENGTH: Dict[str, float] = {'none': 0.0, 'x-weak': 0.1, 'weak': 0.25, 'medium': 0.5, 'strong': 0.75, 'x-strong': 1.0} module-attribute

looks_like_ssml(text: str) -> bool

True if text opens with a <speak> root -- the auto-detect the naive command uses to enable SSML parsing without an explicit --ssml flag.

Source code in src/openfacefx/ssml.py
def looks_like_ssml(text: str) -> bool:
    """True if ``text`` opens with a ``<speak>`` root -- the auto-detect the naive
    command uses to enable SSML parsing without an explicit ``--ssml`` flag."""
    return bool(text) and bool(_SPEAK_RE.match(text))

parse_ssml(text: str) -> Tuple[str, List[Tag]]

Parse an SSML document into (clean_text, tags).

Returns the same pair :func:openfacefx.texttags.parse_tagged_transcript yields for the equivalent bracket transcript, ready for the unchanged naive pipeline: clean_text is the spoken words (aliases substituted, say-as normalized, tags removed) and tags the deterministic :class:Tag list anchored to word indices in clean_text. Raises ValueError on malformed XML (or a DOCTYPE); unknown elements pass through as their text content.

Source code in src/openfacefx/ssml.py
def parse_ssml(text: str) -> Tuple[str, List[Tag]]:
    """Parse an SSML document into ``(clean_text, tags)``.

    Returns the same pair :func:`openfacefx.texttags.parse_tagged_transcript`
    yields for the equivalent bracket transcript, ready for the unchanged naive
    pipeline: ``clean_text`` is the spoken words (aliases substituted, say-as
    normalized, tags removed) and ``tags`` the deterministic :class:`Tag` list
    anchored to word indices in ``clean_text``. Raises ``ValueError`` on malformed
    XML (or a DOCTYPE); unknown elements pass through as their text content."""
    if _DOCTYPE_RE.search(text or ""):
        raise ValueError("SSML with a DOCTYPE is not supported")
    try:
        root = ET.fromstring(text)
    except ET.ParseError as exc:
        raise ValueError(f"malformed SSML: {exc}") from exc
    w = _Walker()
    # The root (<speak>) is a plain container: its text and children are walked,
    # but the root element itself is never a construct -- only <s>/<p> children
    # drop a phrase boundary.
    _walk(root, w)
    return w.clean(), w.tags