SSML to SSMD Conversion
SSMD supports bidirectional conversion: you can convert SSML back to SSMD format. This is useful for editing existing SSML, migrating from other tools, or creating round-trip workflows.
ssmd.from_ssml() returns a complete document with ssmd_version: "0.9" by default.
Set complete_document=False when a body fragment is required. Unrepresentable SSML
semantics raise SSMLConversionError by default. Use SSMLParser with
loss_policy="warn" or "drop" to opt into conversion losses and inspect its
diagnostics property. The feature examples below request fragments where their
expected output shows only the body.
TTS pipeline integration
Use structural parsing when sentence boundaries must be computed after semantic preparation:
SSMD parse -> semantic preparation/normalization -> sentence splitting -> G2P
parsed = ssmd.parse_spans(source)
# Normalize parsed.clean_text and remap annotations before splitting.
ssml = ssmd.to_ssml(source, sentence_spans=sentence_spans)
sentence_spans accepts objects exposing char_start and char_end offsets, or the
three-item tuples returned by iter_sentences_spans(). These offsets must refer to the
structural clean-text coordinate space. SSMD does not normalize numbers or
abbreviations, infer a primary language, or integrate Spokenform/G2P. Protect explicit
ph and ipa ranges during downstream normalization.
Basic Conversion
Using the Convenience Function
import ssmd
# Convert SSML to SSMD
ssml = '<speak><emphasis>Hello</emphasis> world</speak>'
ssmd_text = ssmd.from_ssml(ssml)
print(ssmd_text)
# Complete SSMD 0.9 document beginning with:
# ---
# ssmd_version: '0.9'
# ---
# *Hello* world
Using the Document Class
from ssmd import Document
ssml = '<speak><emphasis>Hello</emphasis> world</speak>'
doc = Document.from_ssml(ssml)
ssmd_text = doc.to_ssmd()
print(ssmd_text)
# Output: *Hello* world
Commonly Representable SSML Elements
Emphasis
# Moderate emphasis
ssmd.from_ssml('<emphasis>text</emphasis>', complete_document=False)
# → *text*
# Strong emphasis
ssmd.from_ssml('<emphasis level="strong">text</emphasis>', complete_document=False)
# → **text**
Breaks
# Time-based breaks
ssmd.from_ssml('<break time="500ms"/>', complete_document=False)
# → ...500ms
ssmd.from_ssml('<break time="2s"/>', complete_document=False)
# → ...2s
# Strength-based breaks
ssmd.from_ssml('<break strength="weak"/>', complete_document=False)
# → ...w
ssmd.from_ssml('<break strength="medium"/>', complete_document=False)
# → ...c
ssmd.from_ssml('<break strength="strong"/>', complete_document=False)
# → ...s
Language
# Full locale
ssmd.from_ssml('<lang xml:lang="fr-FR">Bonjour</lang>', complete_document=False)
# → [Bonjour]{lang="fr"}
# Non-standard locales preserved
ssmd.from_ssml('<lang xml:lang="en-GB">Hello</lang>', complete_document=False)
# → [Hello]{lang="en-GB"}
SSMD’s pronunciation scope is richer than generic SSML:
[File]{lang="en" scope="pronunciation"}
Segment.to_ssml() retains the closest standard mapping, <lang xml:lang="...">,
because SSML has no portable attribute meaning “pronunciation language only”. Generic
engines may therefore change voice or model behavior. Consumers that need the stronger
contract should use parse_spans() or parse_structure() and honor
scope="pronunciation" themselves.
Phonemes
# IPA notation
ssmd.from_ssml(
'<phoneme alphabet="ipa" ph="təˈmeɪtoʊ">tomato</phoneme>', complete_document=False
)
# → [tomato]{ph="təˈmeɪtoʊ" alphabet="ipa"}
# X-SAMPA notation
ssmd.from_ssml(
'<phoneme alphabet="x-sampa" ph="t@meIt@U">tomato</phoneme>', complete_document=False
)
# → [tomato]{ph="t@meIt@U" alphabet="x-sampa"}
Prosody
# Volume
ssmd.from_ssml('<prosody volume="loud">text</prosody>', complete_document=False)
# → [text]{volume="loud"}
ssmd.from_ssml('<prosody volume="x-loud">text</prosody>', complete_document=False)
# → [text]{volume="x-loud"}
# Rate
ssmd.from_ssml('<prosody rate="fast">text</prosody>', complete_document=False)
# → [text]{rate="fast"}
# Pitch
ssmd.from_ssml('<prosody pitch="high">text</prosody>', complete_document=False)
# → [text]{pitch="high"}
# Multiple attributes
ssmd.from_ssml(
'<prosody volume="loud" rate="fast" pitch="high">text</prosody>',
complete_document=False,
)
# → [text]{volume="loud" rate="fast" pitch="high"}
Symbolic shorthand and compact vrp are compatibility-only SSMD input forms. Strict 0.9
documents use named volume, rate, and pitch attributes; migration converts legacy
values when their semantics can be verified.
Say-As
# Basic say-as
ssmd.from_ssml(
'<say-as interpret-as="telephone">+1-555-1234</say-as>', complete_document=False
)
# → [+1-555-1234]{as="telephone"}
# With format attribute
ssmd.from_ssml(
'<say-as interpret-as="date" format="mdy">12/31/2024</say-as>', complete_document=False
)
# → [12/31/2024]{as="date" format="mdy"}
Substitution
ssmd.from_ssml('<sub alias="World Wide Web">WWW</sub>', complete_document=False)
# → [WWW]{sub="World Wide Web"}
Audio
# With description
ssmd.from_ssml('<audio src="sound.mp3">Alternative text</audio>', complete_document=False)
# → [Alternative text]{src="sound.mp3"}
# With desc tag
ssmd.from_ssml('<audio src="bell.mp3"><desc>doorbell</desc></audio>', complete_document=False)
# → [doorbell]{src="bell.mp3"}
# No description
ssmd.from_ssml('<audio src="beep.mp3"></audio>', complete_document=False)
# → []{src="beep.mp3"}
Marks
ssmd.from_ssml('Text <mark name="here"/> more text', complete_document=False)
# → Text @here more text
Paragraphs
ssml = '''<speak>
<p>First paragraph.</p>
<p>Second paragraph.</p>
</speak>'''
ssmd_text = ssmd.from_ssml(ssml, complete_document=False)
# Output:
# First paragraph.
#
# Second paragraph.
Platform Extensions
# Amazon whisper effect
ssml = (
'<speak xmlns:amazon="https://amazon.com/ssml">'
'<amazon:effect name="whispered">secret</amazon:effect></speak>'
)
ssmd.from_ssml(ssml, complete_document=False)
# → [secret]{ext="whisper"}
Default Value Filtering
SSMD automatically removes default/medium values to keep output clean:
# Medium values are filtered out
ssml = '<prosody volume="medium" rate="medium" pitch="medium">text</prosody>'
ssmd.from_ssml(ssml, complete_document=False)
# → text (not [text]{volume="medium" rate="medium" pitch="medium"})
# Only non-default values are included
ssml = '<prosody volume="loud" rate="medium" pitch="medium">text</prosody>'
ssmd.from_ssml(ssml, complete_document=False)
# → [text]{volume="loud"}
Round-Trip Conversion
Convert SSMD → SSML → SSMD preserving semantics:
import ssmd
# Original SSMD
original = '*Hello* [world]{lang="fr"} ...500ms [loud]{volume="loud"}'
# Convert to SSML
ssml = ssmd.to_ssml(original)
print(ssml)
# <speak><emphasis>Hello</emphasis> <lang xml:lang="fr-FR">world</lang>
# <break time="500ms"/> <prosody volume="loud">loud</prosody></speak>
# Convert back to SSMD
restored = ssmd.from_ssml(ssml, complete_document=False)
print(restored)
# *Hello* [world]{lang="fr"} ...500ms [loud]{volume="loud"}
# Semantically equivalent, even if syntax differs slightly
Voice block boundaries are preserved across this conversion. Canonical 0.9 source uses
fenced ::: directives. Raw <div voice="..."> forms are accepted only for legacy
compatibility. Nested emphasis or other supported SSMD markup is reconstructed in a
block form when inline annotation syntax would make it literal text. Round-trip checks
compare semantic text, voice context, annotations, breaks, marks, paragraph structure,
and front matter; formatting-only whitespace changes are allowed.
For paragraph-crossing legacy <div> scopes, migration keeps paragraph boundaries
outside inline annotations. A scope may become separate paragraph-local annotations
rather than a fenced directive when that preserves legacy paragraph structure. Before
writing, migration verifies clean text, effective annotation coverage, structural
events, and front matter, and writes only when equivalence is established.
Complex Examples
Nested Elements
ssml = '''<speak>
<p>
<emphasis>Important:</emphasis>
<lang xml:lang="fr-FR">
<prosody volume="loud">Bonjour</prosody>
</lang>
</p>
</speak>'''
ssmd_text = ssmd.from_ssml(ssml, complete_document=False)
# Output: *Important:* [Bonjour]{lang="fr" volume="loud"}
Mixed Content
ssml = '''<speak>
<p><emphasis>Hello</emphasis> world</p>
<p>This is <prosody volume="loud">important</prosody></p>
<break time="500ms"/>
<p>Goodbye</p>
</speak>'''
ssmd_text = ssmd.from_ssml(ssml, complete_document=False)
# Output:
# *Hello* world
#
# This is [important]{volume="loud"}
#
# ...500ms
#
# Goodbye
Whitespace Handling
SSMD normalizes whitespace during conversion:
# Extra whitespace is normalized
ssml = '''<speak>
<emphasis>
Hello
</emphasis>
world
</speak>'''
ssmd_text = ssmd.from_ssml(ssml, complete_document=False)
# → *Hello* world (whitespace normalized)
Error Handling
Unsupported SSML semantics
Unrepresentable elements raise SSMLConversionError by default. Callers can opt into
warnings or dropping the unsupported wrapper, and must inspect the diagnostics:
import ssmd
ssml = '<speak>Hello <custom>there</custom></speak>'
try:
ssmd.from_ssml(ssml, complete_document=False)
except ssmd.SSMLConversionError as exc:
print(exc.diagnostics)
parser = ssmd.SSMLParser({"loss_policy": "warn"})
fragment = parser.to_ssmd(ssml, complete_document=False)
diagnostics = parser.diagnostics # warning-severity conversion loss
loss_policy="drop" also flattens unsupported wrapper elements while retaining child
text; its diagnostics have informational severity. Neither policy silently hides the
conversion loss.
Malformed XML
try:
ssmd.from_ssml('<speak><emphasis>unclosed</speak>')
except ValueError as exc:
print(f"XML parse error: {exc}")
Configuration Options
Pass a capability profile to Document.from_ssml() only when the conversion should be
adapted to that renderer’s feature subset. Without a profile, recognized SSML semantics
are preserved where SSMD can represent them. See the capability guide
for target-specific rendering
from ssmd import Document
ssml = '<speak><emphasis>Hello</emphasis></speak>'
doc = Document.from_ssml(ssml, capabilities='espeak')
ssmd_text = doc.to_ssmd() # the eSpeak profile omits unsupported emphasis
Use Cases
Migration from Raw SSML
from ssmd import Document
# You have existing SSML files
with open('old_ssml.xml') as f:
ssml = f.read()
# Convert to SSMD for easier editing
doc = Document.from_ssml(ssml)
ssmd_text = doc.to_ssmd()
with open('new_ssmd.ssmd.md', 'w') as f:
f.write(ssmd_text)
SSML Editor Backend
from ssmd import Document
# Load SSML for editing
def load_document(ssml_file):
with open(ssml_file) as f:
ssml = f.read()
return Document.from_ssml(ssml)
# Save as SSML
def save_document(doc, ssml_file):
ssml = doc.to_ssml()
with open(ssml_file, 'w') as f:
f.write(ssml)
Testing and Validation
from ssmd import Document
# Validate SSML by round-trip conversion
def validate_ssml(ssml_text):
try:
doc = Document.from_ssml(ssml_text)
restored_ssml = doc.to_ssml()
return True
except Exception as e:
print(f"Validation failed: {e}")
return False
Limitations
Syntax differences: Round-trip conversion is semantically equivalent but may normalize attribute order or quoting in annotations
Comments lost: XML comments are not preserved
Unknown elements: Conversion rejects unrepresentable semantics by default; explicit
warnordroppolicies return diagnosticsAttribute order: Attribute order may change but semantics are preserved
Whitespace: Whitespace is normalized for readability
Voice Defaults and Transition Metadata
Document.to_ssml() resolves voice_defaults before rendering. For example:
---
ssmd_version: "0.9"
voice_defaults:
guest:
pitch: high
---
:::{voice="guest"}
Hello.
:::
renders the effective pitch as a numeric SSML value such as <prosody pitch="+12%">.
The SSMD source remains concise because inherited values are not written back into the
block. Document.to_ssmd(include_header=True) preserves the header and declared body
attributes.
Natural rate and pitch names are deterministic SSMD authoring levels. They compile to
numeric percentages: rate very-slow through very-fast maps to 65%, 80%, 90%,
100%, 110%, 125%, 150%; pitch very-low through very-high maps to -20%,
-12%, -6%, +0%, +6%, +12%, +20%. Explicit numeric values continue to pass
through.
prosody_transitions is preserved as document metadata and is available through
Document.prosody_transitions. It is not emitted as an invented SSML rate-ramp element.
Standard SSML has no portable speaking-rate contour, so a downstream renderer must apply
any requested transition using its own capabilities.