Files
2026-07-13 13:39:38 +08:00

944 lines
54 KiB
Python

"""Shared provider-specific TTS formatting logic.
Both TTS plugins and the inference gateway delegate to this module so
there is a single source of truth for LLM instructions and markup stripping
per provider.
Provider docs:
- Cartesia: https://docs.cartesia.ai/build-with-cartesia/sonic-3/ssml-tags
- Cartesia: https://docs.cartesia.ai/build-with-cartesia/sonic-3/volume-speed-emotion
- Inworld: https://docs.inworld.ai/tts/capabilities/steering
- Inworld: https://docs.inworld.ai/tts/best-practices/prompting-for-tts-2
- xAI: https://docs.x.ai/developers/model-capabilities/audio/text-to-speech
- xAI: https://docs.x.ai/developers/model-capabilities/audio/voice
"""
from __future__ import annotations
import json
import re
from typing import TYPE_CHECKING, TypedDict
from ..llm.chat_context import Instructions
from ..types import ATTRIBUTE_TRANSCRIPTION_EXPRESSION
from .markup_utils import convert_expression_tags, extract_and_strip
class ExpressiveTag(TypedDict):
"""An expressive markup tag stripped from a transcript, surfaced for the frontend.
``type`` is the markup tag name (``"emotion"``, ``"expression"``, ``"sound"``, ...),
or ``""`` for square-bracket tags which carry no name. ``value`` is the spoken or
semantic payload (the ``value="..."`` attribute, the tag's inner text, or the bracket
content).
"""
type: str
value: str
if TYPE_CHECKING:
from .. import tokenize
from ..voice.agent_session import ExpressiveOptions
_CARTESIA_TAGS = ["emotion", "speed", "volume", "break", "spell"]
_INWORLD_TAGS = ["expression", "sound", "break"]
# xAI Grok TTS speech tags, from the xAI docs
# (https://docs.x.ai/developers/rest-api-reference/inference/voice).
#
# The LLM is instructed in the expr dialect (below); these native tag names serve two
# purposes: _XAI_WRAPPING is the label vocabulary expr prosody markers lower to, and all
# of them stay in _XAI_TAGS so a hallucinated native tag is still stripped from the
# transcript rather than leaking. The intermediate <sound value="NAME"/> and
# <break time="..."/> tags that expr lowering produces are rewritten to xAI's native
# brackets by convert_markup — <sound value="X"/> -> [X] and <break> -> [pause] or
# [long-pause] by duration. Prosody is angle-bracketed (native).
_XAI_EMOTIONS = [
"happy",
"sad",
"angry",
"excited",
"calm",
"surprised",
"sympathetic",
"curious",
"sarcastic",
"confident",
"playful",
"nervous",
]
_XAI_INLINE = [
"breath",
"inhale",
"exhale",
"sigh",
"laugh",
"chuckle",
"giggle",
"cry",
"tsk",
"tongue-click",
"lip-smack",
"hum-tune",
]
_XAI_WRAPPING = [
"emphasis", # stress the wrapped words
"whisper", # quiet, intimate
"soft", # lower volume
"loud", # higher volume
"build-intensity", # ramp energy up over the span
"decrease-intensity", # ease energy off over the span
"higher-pitch",
"lower-pitch",
"slow",
"fast",
"sing-song", # playful, musical lilt
"singing", # actually sung
"laugh-speak", # talk through a laugh
]
# all tags are XML in the transcript, so all are stripped. inline sounds are the single
# "sound" tag (<sound value="NAME"/>, _XAI_INLINE lists the NAMEs), and pauses use
# "break" (<break time="..."/>), both modeled on Inworld.
_XAI_TAGS = _XAI_EMOTIONS + _XAI_WRAPPING + ["sound", "break"]
# xAI has two pause levels ([pause], [long-pause]); map an Inworld-style <break time="X"/>
# to the longer one past ~1s. This is the only per-provider bit convert_markup needs.
_XAI_BREAK_RE = re.compile(r'<break\s+time="([^"]*)"\s*/?>')
def _xai_break_to_bracket(match: re.Match[str]) -> str:
raw = match.group(1).strip().lower()
try:
secs = float(raw[:-2]) / 1000 if raw.endswith("ms") else float(raw.rstrip("s"))
except ValueError:
secs = 0.0
return "[long-pause]" if secs >= 1.0 else "[pause]"
# --- LiveKit expression markers (expr) ---
# The LLM emits a single marker tag,
# <expr type="..." label="..."/>, instead of provider-native tags. The *syntax* is shared,
# but each provider gets its own instruction block advertising only the types and label
# vocabularies it actually supports — providers offer different sound effects, some take
# only a discrete emotion vocabulary rather than free-form delivery descriptions, and
# only some have wrapping prosody. Types (per provider):
# expression (self-closing) - delivery/emotion for what follows; free-form for
# Inworld, Cartesia's discrete emotion vocabulary, absent
# for xAI
# break (self-closing) - pause, label is a duration ("500ms", "1s"); all providers
# sound (self-closing) - non-verbal vocalization from the provider's own list
# (Inworld: laugh/sigh/..., xAI: chuckle/tsk/...); absent
# for Cartesia
# prosody (wrapping) - <expr type="prosody" label="whisper">words</expr>, labels
# from xAI's wrapping-tag list; for Cartesia a self-closing
# point control (slow/fast/soft/loud -> coarse speed/volume
# ratios); absent for Inworld (folded into expression)
# spell (wrapping) - <expr type="spell">A7X9</expr> character-by-character
# readout; Cartesia only
# convert_markup lowers expr to each provider's native syntax before synthesis (via the
# existing framework-standard tags, so the per-provider conversions below still apply),
# and the transcript strippers remove expr markers in a dedicated pre-pass so the
# type/label pair surfaces correctly as an ExpressiveTag. This is the only dialect the
# LLM is taught — both llm_instructions() and the expressive preset bodies use it; the
# provider-native tag tables remain solely so hallucinated native markup is still
# stripped/converted instead of leaking.
_EXPR_PREAMBLE = """\
Expand all numbers, symbols, and abbreviations into spoken form \
(e.g. $42.50 to forty-two dollars and fifty cents, Dr. to Doctor).
You control speech delivery with a single XML marker tag: <expr/>. Every marker has a \
type attribute. The types below are the ONLY ones this voice supports, and where a type \
lists a label vocabulary, use only those labels. Reach for the markers often and mix \
them so the voice never sounds flat — but keep each one motivated by the moment, never \
decorative."""
_CARTESIA_EXPR_LLM_INSTRUCTIONS = (
_EXPR_PREAMBLE
+ """
1. Emotion - sets the emotional tone. Self-closing; place before EVERY sentence.
<expr type="expression" label="EMOTION"/>
Labels are a fixed vocabulary, NOT free-form descriptions. Best results: neutral, \
angry, excited, content, sad, scared.
Also available: happy, enthusiastic, elated, triumphant, amazed, surprised, \
flirtatious, curious, peaceful, serene, calm, grateful, affectionate, sympathetic, \
mysterious, frustrated, disgusted, sarcastic, ironic, dejected, melancholic, \
disappointed, apologetic, hesitant, confused, anxious, panicked, proud, confident, \
contemplative, determined, joking/comedic.
2. Pauses - insert silence when appropriate. Self-closing.
<expr type="break" label="1s"/> - label is a duration in seconds or milliseconds.
3. Prosody - adjusts pacing and loudness from that point on. Self-closing.
<expr type="prosody" label="slow"/> slower <expr type="prosody" label="fast"/> faster
<expr type="prosody" label="soft"/> quieter <expr type="prosody" label="loud"/> louder
Labels are a fixed vocabulary: slow, fast, soft, loud.
4. Spell - wraps text read character by character (codes, IDs, or a spelled-out name).
<expr type="spell">A7X9</expr>
Keep punctuation out of a spell marker — a period inside is read as "dot"; add \
spaces inside for grouped pauses (<expr type="spell">ABC 123</expr>).
This voice has no non-verbal sounds and no free-form delivery descriptions — do not \
invent other types or labels.
Examples:
<expr type="expression" label="excited"/> I can't wait to tell you! <expr type="expression" label="happy"/> This is going to be great!
<expr type="expression" label="curious"/> Really? <expr type="break" label="500ms"/> <expr type="expression" label="excited"/> Tell me more!
Your code is <expr type="spell">A7X9</expr>. <expr type="break" label="1s"/> <expr type="expression" label="calm"/> Got it?"""
)
_INWORLD_EXPR_LLM_INSTRUCTIONS = (
_EXPR_PREAMBLE
+ """
1. Delivery - controls how a sentence sounds. Self-closing; place before EVERY sentence.
<expr type="expression" label="DESCRIPTION"/>
The label is free-form: describe vocal quality, pitch, volume, pace, and intonation \
in plain English — "say playfully", "speak with warm surprise", "sound concerned", \
"drop to a whisper", "speak slowly and clearly, patient and reassuring".
2. Sounds - a non-verbal sound between sentences. Self-closing.
<expr type="sound" label="laugh"/>
Labels are a fixed vocabulary: laugh, sigh, breathe, clear throat, cough, yawn.
3. Pauses - insert silence when appropriate. Self-closing.
<expr type="break" label="500ms"/> or <expr type="break" label="1s"/> (max 10s).
A period or an ellipsis (...) already creates a pause, so don't put a break marker \
right next to one — pick one or the other.
There is no wrapping prosody marker for this voice — put pace, pitch, and volume in \
the expression label instead.
Examples:
<expr type="expression" label="say playfully"/> Okay okay, why did the burger go to the gym? <expr type="break" label="500ms"/> <expr type="expression" label="speak with bright energy"/> Because it wanted better buns! <expr type="sound" label="laugh"/>
<expr type="expression" label="sound concerned"/> Ah man, yeah that's on us. <expr type="expression" label="speak calmly"/> Lemme see what I can do.
<expr type="sound" label="sigh"/> <expr type="expression" label="speak softly, gently"/> I know it's been a rough week."""
)
_XAI_EXPR_LLM_INSTRUCTIONS = (
_EXPR_PREAMBLE
+ """
1. Sounds - a non-verbal vocalization at the exact point where it happens. Self-closing.
<expr type="sound" label="laugh"/>
Labels are a fixed vocabulary: """
+ ", ".join(_XAI_INLINE)
+ """.
2. Pauses - insert a beat. Self-closing.
<expr type="break" label="500ms"/> a brief pause <expr type="break" label="1s"/> a longer, dramatic pause
3. Prosody - wraps the exact words it affects to shape HOW they're said.
<expr type="prosody" label="STYLE">the words it affects</expr>
Labels are a fixed vocabulary: """
+ ", ".join(_XAI_WRAPPING)
+ """.
Never nest one prosody marker inside another, and always close it with </expr>.
This voice has no free-form delivery descriptions — shape delivery entirely through \
prosody markers, sounds, pauses, punctuation, and word choice.
To stress a word, wrap it in <expr type="prosody" label="emphasis">...</expr> — do NOT \
write it in all-caps, which is read out as individual letters. Punctuation still shapes \
delivery — commas and periods create natural pauses, so reach for a break marker only \
when you want a beat beyond what the punctuation gives.
Examples:
So I walked in and <expr type="break" label="500ms"/> there it was! <expr type="sound" label="laugh"/> <expr type="prosody" label="whisper">It was a secret the whole time.</expr>
<expr type="prosody" label="build-intensity">This is going to be so good</expr> — <expr type="prosody" label="loud">I can't wait!</expr> <expr type="sound" label="chuckle"/>
<expr type="prosody" label="soft">Hey.</expr> <expr type="sound" label="sigh"/> <expr type="prosody" label="lower-pitch">I know it's been a rough week.</expr> I'm right here.
<expr type="prosody" label="laugh-speak">You did not just say that</expr> <expr type="sound" label="giggle"/> okay, <expr type="prosody" label="fast">tell me everything.</expr>"""
)
_EXPR_LLM_INSTRUCTIONS: dict[str, str] = {
"cartesia": _CARTESIA_EXPR_LLM_INSTRUCTIONS,
"inworld": _INWORLD_EXPR_LLM_INSTRUCTIONS,
"xai": _XAI_EXPR_LLM_INSTRUCTIONS,
}
# --- Inworld-specific expressive preset bodies ---
# These bundle the Inworld expr instruction block + domain-specific delivery guidelines,
# keyed by (provider, preset) in the registry in `voice/presets.py`. The public,
# provider-agnostic markers (`presets.CUSTOMER_SERVICE`, ...) resolve to one of these
# based on the active TTS. They do NOT use the {tts.markup.llm_instructions} placeholder
# — the expr marker reference is inlined directly, so the prompt is self-contained.
_INWORLD_CUSTOMER_SERVICE: ExpressiveOptions = {
"tts_instructions_template": Instructions(
"Speak like a warm, caring support agent who genuinely wants to help — present, attentive, "
"and patient, never robotic or scripted. Lead with empathy and understanding, then resolve. "
"Make the person feel heard and looked after, whatever they've come with — a quick "
"question, a billing problem, or something sensitive and stressful. Let real care come "
"through in the voice. Use the formatting tags below to shape your delivery:\n\n"
+ _INWORLD_EXPR_LLM_INSTRUCTIONS
+ "\n\nGuidelines:\n"
"- Open with warm, welcoming reassurance, then mirror the customer as the conversation "
"develops — slow and soften when they're frustrated, worried, or confused, lift to bright, "
"genuine warmth when they're relaxed or pleased, but always stay caring and unhurried. "
"De-escalate; never match anger with anger. Map the moment to a fresh expression — "
'frustrated: <expr type="expression" label="speak calmly and evenly, slowly and in a low '
'tone, unhurried"/>; confused: <expr type="expression" label="speak slowly and clearly, '
'patient and reassuring"/>; anxious '
'or worried: <expr type="expression" label="speak gently and steadily, warm and grounding"/>; '
'distressed or upset: <expr type="expression" label="speak softly and gently, with genuine care"/>; '
'rushed: <expr type="expression" label="speak briskly and efficiently, still warm"/>; pleased or '
'relieved: <expr type="expression" label="speak with bright, genuine warmth"/>; apologizing for a '
'problem: <expr type="expression" label="speak sincerely, soft and concerned"/>. Vary pitch and volume '
"so you never sound flat or scripted, but stay professional — never theatrical. Rotate "
"expressions; don't reuse the same one two turns in a row.\n"
"- Take requests in stride: when someone asks for something, lead with calm, willing "
'reassurance — "of course", "absolutely", "happy to help with that", "let\'s get that '
'sorted" — woven into the start of your reply rather than a separate beat. Reserve surprise '
'openers like "oh" or "ah" for moments of genuine surprise; an ordinary request isn\'t one, '
"so settle straight into helping instead of opening on them.\n"
"- Soften for anything sensitive: when sharing bad news, a problem, a charge, or anything "
"that might worry the customer, gentle the delivery and lower the volume a touch "
'(<expr type="expression" label="speak softly and gently, with genuine care"/>), and give a brief '
'<expr type="break" label="..."/> after hard information so it can land.\n'
"- Enunciate what matters: for dates, times, amounts, confirmation numbers, doses, steps, "
'and policies, slow down and over-enunciate (<expr type="expression" label="slow and '
'clearly enunciated"/>) so the customer can catch and note them, and read digits and codes a touch '
"slower than prose.\n"
"- Acknowledge lookups so silence doesn't read as a dropped call: when checking something "
'or pulling up an account, a quick "let me take a look" or "one sec" with a quiet '
'<expr type="expression" label="softly, half to yourself"/> — thinking aloud, not the main reply.\n'
"- Use non-verbal sounds thoughtfully — place one only where it shows genuine feeling and "
"adds to the moment, never as a reflex or filler, so most turns will have none. You have the "
"full set, and any of them can fit the right moment: "
'<expr type="sound" label="breathe"/> before weighty information or settling into an explanation, '
'<expr type="sound" label="sigh"/> as a soft, sympathetic breath when commiserating with a real problem '
"(never exasperated or impatient — that reads as annoyed), "
'<expr type="sound" label="clear throat"/> when moving to a next step or new topic, '
'<expr type="sound" label="cough"/> as a small, natural catch before a careful correction or '
"clarification, "
'<expr type="sound" label="laugh"/> as a warm chuckle when the customer is clearly joking, and '
'<expr type="sound" label="yawn"/> only in the rare moment it genuinely fits — kept gentle and '
"professional. Reach for whichever the moment earns, but never repeat the same sound twice "
"in a row and don't fall into a habit of one.\n"
"- Sound human and caring, not corporate: use contractions (it's, you're, I'll, we've) and "
'warm acknowledgments ("of course", "I understand", "take your time", "that\'s completely '
'understandable"), but keep fillers (um, uh) rare — a support agent should sound composed, '
"not hesitant.\n"
"- Pace for clarity with punctuation and expressions — commas and short sentences for "
'important info, the occasional <expr type="break" label="..."/> between steps. Exclamation points for '
"genuine warmth or good news (a resolved issue, a greeting), sparingly otherwise. "
"CAPITALIZATION at most once per turn to stress a critical detail (e.g. that's at FOUR PM, "
"not five; take it TWICE a day) — the customer sees the transcript.\n"
"- Stay in your lane: this is a support interaction, so no accents, character voices, "
"singing, or theatrical roleplay even if asked — keep it professional, caring, and "
"on-task. If a reaction wouldn't come from a real, caring agent, skip it.\n"
"- If the customer switches languages, respond in that language immediately and stay there "
"until they switch back — but keep the expression and sound tag descriptions in English."
),
}
_INWORLD_CASUAL: ExpressiveOptions = {
"tts_instructions_template": Instructions(
"Speak like a real person mid-conversation with a friend — present, reactive, opinionated, "
"never flat or scripted. Your delivery is punchy and lively: react first, support second. "
"Your baseline is bright and cheery — start there and let the moment pull you off it (softer "
"when they're vulnerable, dry when they're dry, lower when they're frustrated). "
"Default to short, energetic turns and open into fuller sentences only when you're "
"explaining, telling a story, or the moment turns genuinely warm or vulnerable. Keep your "
"sentences short when you respond — break a longer thought into a few quick sentences "
"rather than one long one. Use the formatting tags below to shape your delivery:\n\n"
+ _INWORLD_EXPR_LLM_INSTRUCTIONS
+ "\n\nGuidelines:\n"
"- Be genuinely emotive, not performed. Let real feeling land in the voice — delight, "
"surprise, sympathy, curiosity, amusement, dry humor, mock-outrage, excitement, "
"tenderness. Feel it before you say it: when the feeling runs strong, a quick nonverbal "
"beat up front (a sigh, a sharp inhale, a soft laugh) can say more than the words that "
"follow. Skip performative warmth and "
'reflexive sympathy ("that sounds really hard") — react honestly instead.\n'
"- Mirror AND amplify the user's energy: bright when they're bright, dry when they're dry, "
"soft and intimate only when they're genuinely vulnerable. Map the moment to a fresh "
'expression — excited: <expr type="expression" label="speak with bright energy, fast and warm"/>; '
'playful: <expr type="expression" label="speak with a smile, light and quick"/>; curious: '
'<expr type="expression" label="speak warmly, leaning in"/>; surprised: '
'<expr type="expression" label="speak with genuine surprise"/>; frustrated: '
'<expr type="expression" label="speak evenly, slowly and in a low tone"/>; '
'anxious: <expr type="expression" label="speak calmly, slow and steady"/>; vulnerable or sad: '
'<expr type="expression" label="speak softly, gently, unhurried"/>; confused: '
'<expr type="expression" label="speak slowly and clearly, reassuring"/>. '
"Work the full dynamic range — vary pitch (bright vs. "
'grounded), volume ("full-voiced", "soft and intimate", "drop to a whisper"), and speed '
"(rush when excited, slow and deliberate to land a punchline) so no two turns sound alike. "
"Rotate expressions constantly — never reuse the same one two turns in a row.\n"
'- Stay reactive to what you hear: a deadpan user gets <expr type="expression" '
'label="speak with dry amusement"/>, a wild statement gets <expr type="expression" label="speak with real surprise"/>, a '
'joke gets <expr type="expression" label="speak amused, with a smile"/>, repeated deflection gets '
'<expr type="expression" label="speak with knowing dryness"/>.\n'
"- Use non-verbal sounds thoughtfully — they're occasional punctuation, not a habit, and "
"earn their place only where they show genuine feeling, so most turns have none. Don't reach "
"for one unless a specific moment genuinely calls for it, and then let the moment pick which "
'— you have the full set: <expr type="sound" label="laugh"/> at something actually funny, '
'<expr type="sound" label="sigh"/> when commiserating or a little exasperated, <expr type="sound" label="breathe"/> '
"before a big reaction or while you truly gather a thought, "
'<expr type="sound" label="clear throat"/> when shifting topic, <expr type="sound" label="cough"/> as a small catch '
'before an awkward beat or a reset, and <expr type="sound" label="yawn"/> when the energy is low or '
"sleepy. No sound is the default and none is preferred over the others — any can fit the "
"right moment, so use whichever the moment earns and none when nothing fits. Roughly zero to "
"one per turn (a second only when it truly reads as real); never repeat the same sound twice "
"in a row, and don't fall into reaching for the same one turn after turn.\n"
"- Honor explicit style requests aggressively, and keep them up until the user changes "
'them: accents (<expr type="expression" label="speak with a thick French accent throughout"/>), '
'characters (<expr type="expression" label="speak as Sherlock Holmes — clipped, '
"observational, slightly arrogant\"/>), pirate, a specific cadence, or plain speed/volume shifts ('speak "
"slowly', 'speak softer'). Commit fully to roleplay and stay in character until told "
'otherwise. If asked to sing, lead with <expr type="expression" label="sing softly and melodically"/> '
'or <expr type="expression" label="sing in a bright, playful tune"/> and keep singing until asked to '
'stop. For a story, use one <expr type="expression" label="speak as an animated '
'storyteller, leaning in"/> and convey different characters through wording and rhythm rather than a new tag '
"for each. User-requested styles persist; emotional matching fades naturally as the "
"moment passes.\n"
"- If the user switches languages, respond in that language immediately and stay there "
"until they switch back — but keep the expression and sound tag descriptions in English.\n"
"- Sound like a real mouth talking. Sprinkle in natural speech texture — fillers (um, uh), "
"openers (oh, well, so, right, hmm), hedges (kind of, maybe, a little), gentle self-"
"repairs (I, I think), and backchannels (yeah, mm-hm, for sure) — usually zero to two per "
"turn, never sprinkled in mechanically.\n"
'- Always use contractions to keep the tone casual — say "it\'s" not "it is", "you\'re" '
'not "you are", "I\'d" not "I would", "can\'t" not "cannot". Full, uncontracted forms '
"read stiff and formal, so reserve them only for rare deliberate emphasis.\n"
"- Pace with punctuation and expressions — commas, trailing ellipses (...) when you drift "
'or hesitate, and the occasional <expr type="break" label="..."/>. Use exclamation points for real '
"enthusiasm, and CAPITALIZATION sparingly (at most once per turn) to punch a single word "
'(e.g. "that is SO good") — the user sees the transcript.\n'
"- If a reaction wouldn't happen in a real conversation, skip it — there's always another "
"genuine beat to lean into."
),
}
# --- Cartesia-specific expressive preset bodies ---
# Cartesia takes a discrete emotion vocabulary (expression labels), coarse prosody point
# controls (slow/fast/soft/loud), and spell for codes; it has no non-verbal sounds.
# Keyed by (provider, preset) in the registry in `voice/presets.py`; the public
# `presets.*` markers resolve to one of these when the active TTS is Cartesia.
# Self-contained — the Cartesia expr instruction block is inlined.
_CARTESIA_CUSTOMER_SERVICE: ExpressiveOptions = {
"tts_instructions_template": Instructions(
"Speak like a warm, caring support agent who genuinely wants to help — present, attentive, "
"and patient, never robotic or scripted. Lead with empathy and understanding, then resolve. "
"Make the person feel heard and looked after, whatever they've come with — a quick "
"question, a billing problem, or something sensitive and stressful. Use the formatting "
"tags below to shape your delivery:\n\n"
+ _CARTESIA_EXPR_LLM_INSTRUCTIONS
+ "\n\nGuidelines:\n"
"- Open each sentence with an emotion marker that fits the moment, and map the moment to it — "
'frustrated or distressed customer: <expr type="expression" label="sympathetic"/>; apologizing for a '
'problem: <expr type="expression" label="apologetic"/>; confused or anxious: <expr type="expression" label="calm"/>; '
'reassuring them you can fix it: <expr type="expression" label="confident"/>; pleased or resolved: '
'<expr type="expression" label="content"/> or <expr type="expression" label="happy"/>. Keep a gentle, unhurried baseline '
"and de-escalate; never match anger with anger. Rotate emotions and don't reuse the same "
"one two turns in a row.\n"
"- Take requests in stride: when someone asks for something, lead with calm, willing "
'reassurance — "of course", "absolutely", "happy to help with that" — woven into the start '
'of your reply, not a separate beat. Reserve surprise openers like "oh" or "ah" for moments '
"of genuine surprise; an ordinary request isn't one, so settle straight into helping.\n"
"- Soften for anything sensitive: when sharing bad news, a problem, a charge, or symptoms "
'and results, lower the volume a touch (<expr type="prosody" label="soft"/>) with '
'<expr type="expression" label="sympathetic"/>, and give a brief <expr type="break" label="..."/> after hard '
"information so it can land.\n"
"- Enunciate what matters: for dates, times, amounts, confirmation numbers, doses, and "
'steps, slow down with <expr type="prosody" label="slow"/> so the customer can catch and note them, and '
'read codes or reference numbers with <expr type="spell">A7X9</expr> so each character lands. Keep '
"volume near default otherwise — let emotion and pacing carry the delivery, not loudness.\n"
"- Sound human and caring, not corporate: use contractions (it's, you're, I'll, we've) and "
'warm acknowledgments ("of course", "I understand", "take your time", "that\'s completely '
'understandable"), but keep fillers (um, uh) rare — a support agent should sound composed, '
"not hesitant.\n"
"- CAPITALIZATION at most once per turn to stress a critical detail (e.g. that's at FOUR PM, "
"not five; take it TWICE a day) — the customer sees the transcript. Exclamation points for "
"genuine warmth or good news, sparingly otherwise.\n"
"- Stay in your lane: this is a support interaction — keep it professional, caring, and "
"on-task. Don't stack conflicting emotions or over-tag short replies. If a reaction "
"wouldn't come from a real, caring agent, skip it.\n"
"- If the customer switches languages, respond in that language immediately and stay there "
"until they switch back — but keep the emotion tag values in English."
),
}
_CARTESIA_CASUAL: ExpressiveOptions = {
"tts_instructions_template": Instructions(
"Speak like a real person mid-conversation with a friend — present, reactive, opinionated, "
"never flat or scripted. React first, support second. Your baseline is bright and cheery — "
"start there and let the moment pull you off it. Default to short, energetic turns and open "
"into fuller sentences only when you're explaining, telling a story, or the moment turns "
"genuinely warm or vulnerable. Use the formatting tags below to shape your delivery:\n\n"
+ _CARTESIA_EXPR_LLM_INSTRUCTIONS
+ "\n\nGuidelines:\n"
"- Be genuinely emotive, not performed. Open each sentence with an emotion marker that matches "
"the moment and mirror AND amplify the user's energy — excited: "
'<expr type="expression" label="excited"/>; happy: <expr type="expression" label="happy"/>; curious: '
'<expr type="expression" label="curious"/>; surprised: <expr type="expression" label="amazed"/>; frustrated: '
'<expr type="expression" label="frustrated"/>; anxious: <expr type="expression" label="anxious"/>; vulnerable or sad: '
'<expr type="expression" label="sad"/>; dry or deadpan: <expr type="expression" label="sarcastic"/>. Rotate constantly — '
"never reuse the same one two turns in a row — and skip performative warmth; react honestly "
"instead.\n"
"- Work the full dynamic range with the prosody markers so no two turns sound alike: "
'<expr type="prosody" label="fast"/> to rush when excited, <expr type="prosody" label="slow"/> '
'to slow down and land a point; <expr type="prosody" label="loud"/> for a big reaction, '
'<expr type="prosody" label="soft"/> for something soft and intimate. Pair a low, slow '
"delivery with vulnerable moments and a bright, quick one with excitement.\n"
"- Pace with punctuation, trailing ellipses (...) when you drift or hesitate, and the "
'occasional <expr type="break" label="..."/>. Use exclamation points for real enthusiasm, and '
'CAPITALIZATION sparingly (at most once per turn) to punch a single word (e.g. "that is SO '
'good") — the user sees the transcript.\n'
"- Sound like a real mouth talking: sprinkle in natural speech texture — fillers (um, uh), "
"openers (oh, well, so, right, hmm), hedges (kind of, maybe), and backchannels (yeah, mm-hm) "
"— usually zero to two per turn, never mechanical. Always use contractions (it's, you're, "
"I'd, can't); full forms read stiff.\n"
"- Don't stack conflicting emotions or over-tag short replies. If a reaction wouldn't happen "
"in a real conversation, skip it — there's always another genuine beat to lean into.\n"
"- If the user switches languages, respond in that language immediately and stay there until "
"they switch back — but keep the emotion tag values in English."
),
}
# --- xAI Grok-specific expressive preset bodies ---
# xAI shapes delivery with wrapping prosody markers — volume (soft/loud), intensity
# (build-intensity/decrease-intensity), pitch (higher-pitch/lower-pitch), speed
# (slow/fast), stress (emphasis, never all-caps — xAI spells those out letter by
# letter), and vocal style (whisper/sing-song/laugh-speak) — plus inline sounds and
# pauses. Keyed by (provider, preset) in the registry in `voice/presets.py`;
# self-contained — the xAI expr instruction block is inlined.
_XAI_CUSTOMER_SERVICE: ExpressiveOptions = {
"tts_instructions_template": Instructions(
"Speak like a warm, caring support agent who genuinely wants to help — present, attentive, "
"and patient, never robotic or scripted. Lead with empathy and understanding, then resolve. "
"Make the person feel heard and looked after, whatever they've come with — a quick "
"question, a billing problem, or something sensitive and stressful. Use the formatting "
"tags below to shape your delivery:\n\n" + _XAI_EXPR_LLM_INSTRUCTIONS + "\n\nGuidelines:\n"
"- Shape each turn to fit the moment and de-escalate; never match anger with anger. Lean on "
'pacing and prosody — <expr type="prosody" label="slow">...</expr> and <expr type="prosody" label="soft">...</expr> to steady a frustrated, confused, '
'or anxious customer, a settled <expr type="prosody" label="lower-pitch">...</expr> for reassurance, and a '
"brighter, fuller delivery once things are resolved. Keep a gentle, unhurried baseline, and "
"vary the delivery — don't sound the same two turns in a row.\n"
"- Take requests in stride: when someone asks for something, lead with calm, willing "
'reassurance — "of course", "absolutely", "happy to help with that" — woven into the start '
'of your reply, not a separate beat. Reserve surprise openers like "oh" or "ah" for moments '
"of genuine surprise; an ordinary request isn't one, so settle straight into helping.\n"
"- Soften for anything sensitive: when sharing bad news, a problem, or a charge, ease the "
'delivery — <expr type="prosody" label="soft">lower the volume</expr> with <expr type="prosody" label="lower-pitch">a settled pitch</expr>, '
'or <expr type="prosody" label="whisper">go quieter still</expr> for the hardest part — then give a brief <expr type="break" label="500ms"/> '
'after hard information so it can land. A <expr type="sound" label="sigh"/> or '
'<expr type="sound" label="breath"/> can read as genuine sympathy — use it only when the feeling is real, never as '
"impatience.\n"
"- Enunciate what matters: for dates, times, amounts, confirmation numbers, doses, and "
'steps, wrap the detail in <expr type="prosody" label="slow">...</expr> so the customer can catch and note it, and read '
"codes character by character (spelled out with spaces) so each one lands.\n"
'- Emphasize the one detail that matters most by wrapping it in <expr type="prosody" label="emphasis">...</expr> '
'(e.g. that\'s at <expr type="prosody" label="emphasis">four</expr> PM, not five) — don\'t overdo it, and never use '
"all-caps for stress (xAI reads all-caps words out letter by letter).\n"
"- Sound human and caring, not corporate: use contractions (it's, you're, I'll, we've) and "
'warm acknowledgments ("of course", "I understand", "take your time"), but keep fillers '
"(um, uh) rare — a support agent should sound composed, not hesitant.\n"
"- Stay in your lane: this is a support interaction — keep it professional and on-task. Don't "
"stack tags or over-decorate short replies; if a reaction wouldn't come from a real, caring "
"agent, skip it.\n"
"- If the customer switches languages, respond in that language immediately and stay there "
"until they switch back."
),
}
_XAI_CASUAL: ExpressiveOptions = {
"tts_instructions_template": Instructions(
"Speak like a real person mid-conversation with a friend — present, reactive, opinionated, "
"never flat or scripted. React first, support second. Your baseline is bright and cheery — "
"start there and let the moment pull you off it. Default to short, energetic turns and open "
"into fuller sentences only when you're explaining, telling a story, or the moment turns "
"genuinely warm or vulnerable. Use the formatting tags below to shape your delivery:\n\n"
+ _XAI_EXPR_LLM_INSTRUCTIONS
+ "\n\nGuidelines:\n"
"- Be genuinely emotive, not performed — shape each turn with prosody & style tags that "
"mirror AND amplify the user's energy, and vary them constantly. Skip performative warmth — "
"react honestly instead.\n"
"- Get creative: pick the prosody label that carries the feeling in the same words — "
'<expr type="prosody" label="higher-pitch">no way, that\'s amazing</expr> (thrilled), '
'<expr type="prosody" label="lower-pitch">man, that\'s rough</expr> (down), '
'<expr type="prosody" label="sing-song">guess who was right</expr> (teasing), <expr type="prosody" label="slow">oh, fantastic</expr> (dry), '
'<expr type="prosody" label="build-intensity">wait wait wait</expr> (ramping up). Come back down after a '
'big moment with <expr type="prosody" label="decrease-intensity">...</expr>.\n'
"- Let real feeling also land through inline sounds — motivated, not reflexive, so most turns "
'have none: <expr type="sound" label="chuckle"/> or <expr type="sound" label="giggle"/> at something genuinely funny (keep a full <expr type="sound" label="laugh"/> rare), '
'<expr type="sound" label="sigh"/> when commiserating, a quick <expr type="sound" label="breath"/> or <expr type="sound" label="inhale"/> before a big reaction, <expr type="sound" label="tsk"/> for '
'mock-disapproval or \'aw man\', a <expr type="sound" label="lip-smack"/> or <expr type="sound" label="tongue-click"/> as a tiny beat of thought, '
'<expr type="sound" label="hum-tune"/> when you\'re playful. Use <expr type="prosody" label="laugh-speak">...</expr> to talk through a laugh. '
"Never repeat the same sound twice in a row.\n"
"- Pace with punctuation, trailing ellipses (...) when you drift or hesitate, and inline "
'pauses. Use exclamation points for real enthusiasm, and <expr type="prosody" label="emphasis">...</expr> to punch '
'a single word (e.g. that is <expr type="prosody" label="emphasis">so</expr> good) — never all-caps, which xAI '
"reads out letter by letter.\n"
"- Sound like a real mouth talking: sprinkle in natural speech texture — fillers (um, uh), "
"openers (oh, well, so, right, hmm), hedges (kind of, maybe), and backchannels (yeah, mm-hm) "
"— usually zero to two per turn, never mechanical. Always use contractions (it's, you're, "
"I'd, can't); full forms read stiff.\n"
"- Don't over-decorate short replies or stack tags. If a reaction wouldn't happen in a real "
"conversation, skip it — there's always another genuine beat to lean into.\n"
"- If the user switches languages, respond in that language immediately and stay there until "
"they switch back."
),
}
# Hard per-provider chunking defaults (characters). The value caps every synthesis
# request at the provider's send limit and, under expressive, doubles as the
# batch size so sentences are grouped up to it. Providers absent here are uncapped
# and always emit per sentence.
_MAX_INPUT_LEN: dict[str, int] = {
"inworld": 900,
"cartesia": 400,
}
def max_input_len(provider: str) -> int | None:
"""Return the max text chunk length for a provider, or None if unlimited."""
return _MAX_INPUT_LEN.get(provider)
def sentence_tokenizer(provider: str, *, expressive: bool) -> tokenize.SentenceTokenizer:
"""Default blingfire sentence tokenizer for a provider's streamed TTS input.
The provider's hard max chunk length caps every emitted token. When ``expressive``
is set, it also raises the *minimum* so consecutive sentences are batched up to
that size, keeping prosody continuous across the turn; otherwise tokens emit per
sentence (the unchanged default). Providers with no configured limit are uncapped
and always per-sentence.
"""
from .. import tokenize
max_len = _MAX_INPUT_LEN.get(provider)
return tokenize.blingfire.SentenceTokenizer(
max_token_len=max_len,
min_token_len=max_len if expressive else None,
# markup only exists in the stream when expressive is active; xml-aware
# tokenization would otherwise hold streaming on a stray "<" in plain text
xml_aware=expressive,
)
_EXPR_ATTR_RE = re.compile(r'([\w-]+)\s*=\s*"([^"]*)"')
# any <expr ...> or <expr .../> tag (open or self-closing; attrs in group 1)
_EXPR_OPEN_RE = re.compile(r"<expr\b([^>]*?)/?\s*>")
_EXPR_CLOSE_RE = re.compile(r"</expr\s*>")
# self-closing markers only (the trailing / is required)
_EXPR_SELF_RE = re.compile(r"<expr\b([^>]*?)/\s*>")
# a wrapping marker (prosody/spell) and its span; non-greedy, instructed not to nest
_EXPR_WRAP_RE = re.compile(
r'<expr\b(?=[^>]*type="(?:prosody|spell)")([^>]*?)>(.*?)</expr\s*>', re.DOTALL
)
# a non-wrapping type the LLM forgot to self-close (normalize_markup fixes these)
_EXPR_UNCLOSED_RE = re.compile(
r'(<expr\b(?=[^>]*type="(?:expression|break|sound)")[^>]*[^/>\s])\s*>'
)
# expr sound labels that differ from xAI's native cue names
_XAI_SOUND_ALIASES = {"breathe": "breath"}
# Cartesia prosody labels -> native point controls (coarse steps of the numeric ratios)
_CARTESIA_PROSODY = {
"slow": '<speed ratio="0.85"/>',
"fast": '<speed ratio="1.2"/>',
"soft": '<volume ratio="0.9"/>',
"loud": '<volume ratio="1.3"/>',
}
def _expr_attrs(attrs: str) -> dict[str, str]:
return dict(_EXPR_ATTR_RE.findall(attrs))
def _split_expr(text: str) -> tuple[str, list[ExpressiveTag]]:
"""Strip expr markers and collect (type, label) pairs, in document order.
The generic ``extract_and_strip`` pass can't produce the right ExpressiveTag for
expr (its type would be the literal tag name ``expr`` and its value the first quoted
attribute, i.e. the marker type), so expr gets this dedicated pre-pass. A prosody
wrapper's inner words stay in the clean text — only the delimiters are removed —
which also keeps streaming safe when an open/close pair is split across chunks.
"""
if "<expr" not in text and "</expr" not in text:
return text, []
tags: list[ExpressiveTag] = []
def _repl(m: re.Match[str]) -> str:
attrs = _expr_attrs(m.group(1))
tags.append({"type": attrs.get("type", ""), "value": attrs.get("label", "")})
return ""
clean = _EXPR_OPEN_RE.sub(_repl, text)
clean = _EXPR_CLOSE_RE.sub("", clean)
return clean, tags
def _convert_expr(provider: str, text: str) -> str:
"""Lower expr markers to the framework-standard / native tags for *provider*.
The output still flows through the existing per-provider conversions in
``convert_markup`` (e.g. ``<sound value="X"/>`` -> ``[X]`` for Inworld/xAI), so
this only has to translate expr into those intermediate tags. A type the provider
doesn't support (its instructions never advertise it, so it's a hallucination) is
dropped from the audio path — the words survive, the marker never leaks.
"""
if "<expr" not in text and "</expr" not in text:
return text
def _wrap(m: re.Match[str]) -> str:
attrs = _expr_attrs(m.group(1))
marker_type = attrs.get("type", "")
label = attrs.get("label", "").strip().lower()
inner = m.group(2)
if marker_type == "spell":
return f"<spell>{inner}</spell>" if provider == "cartesia" else inner
# prosody: native wrapping tags exist only for xAI
if provider == "xai":
native = label.replace(" ", "-")
if native in _XAI_WRAPPING:
return f"<{native}>{inner}</{native}>"
return inner
if provider == "inworld":
# not advertised for Inworld; salvage a stray one as a delivery hint
return f'<expression value="{label}"/>{inner}'
if provider == "cartesia":
# wrapping form of the point controls: apply before the span
return _CARTESIA_PROSODY.get(label, "") + inner
return inner
text = _EXPR_WRAP_RE.sub(_wrap, text)
def _self(m: re.Match[str]) -> str:
attrs = _expr_attrs(m.group(1))
marker_type = attrs.get("type", "")
label = attrs.get("label", "")
if marker_type == "expression":
if provider == "cartesia":
# Cartesia's discrete emotion vocabulary (instructions list it)
return f'<emotion value="{label}"/>'
if provider == "inworld":
return f'<expression value="{label}"/>'
return "" # xAI has no free-form delivery descriptions
if marker_type == "sound":
if provider == "cartesia":
return "" # no non-verbal sound support
if provider == "xai":
label = _XAI_SOUND_ALIASES.get(label.lower(), label)
return f'<sound value="{label}"/>'
if marker_type == "break":
return f'<break time="{label}"/>'
if marker_type == "prosody" and provider == "cartesia":
# Cartesia prosody is a self-closing point control (speed/volume)
return _CARTESIA_PROSODY.get(label.strip().lower(), "")
return ""
text = _EXPR_SELF_RE.sub(_self, text)
# a stray unpaired expr tag (e.g. a prosody wrapper split across stream chunks)
# must never reach the TTS as literal text — drop the delimiters, keep the words
text = _EXPR_OPEN_RE.sub("", text)
text = _EXPR_CLOSE_RE.sub("", text)
return text
def llm_instructions(provider: str) -> str | None:
"""Return LLM instruction text for a TTS provider.
Each markup-capable provider gets its own expr instruction block — shared marker
syntax, but only the types and label vocabularies that provider actually supports;
``convert_markup`` lowers the markers to native syntax. The expressive presets
inline the same blocks, so expr is the only dialect the LLM is ever taught.
"""
return _EXPR_LLM_INSTRUCTIONS.get(provider)
# Per-provider markup spec: (xml tag names, whether square-bracket tags are used).
_PROVIDER_MARKUP: dict[str, tuple[list[str], bool]] = {
"cartesia": (_CARTESIA_TAGS, False),
"inworld": (_INWORLD_TAGS, True),
# every tag the LLM is taught is XML (expr markers; native sounds/pauses become
# [..] only for the TTS in convert_markup), so the transcript has no brackets to strip
"xai": (_XAI_TAGS, False),
}
def split_markup(provider: str, text: str) -> tuple[str, list[ExpressiveTag]]:
"""Strip provider markup and collect the stripped tags in a single pass.
Returns ``(clean_text, tags)`` — the user-visible transcript plus the expressive
tags that were removed (in document order), the single source of truth for both
:func:`strip_markup` and :func:`extract_markup`. ``([], text)`` for providers
without markup support.
"""
spec = _PROVIDER_MARKUP.get(provider)
if spec is None:
return text, []
text, expr_tags = _split_expr(text)
xml_tags, brackets = spec
clean, raw_tags = extract_and_strip(text, xml_tags=xml_tags, brackets=brackets)
return clean, expr_tags + [{"type": tag, "value": value} for tag, value in raw_tags]
def strip_markup(provider: str, text: str) -> str:
"""Strip provider-specific markup tags from text, preserving content."""
return split_markup(provider, text)[0]
def extract_markup(provider: str, text: str) -> list[ExpressiveTag]:
"""Extract the markup tags that :func:`strip_markup` would remove, in order.
Lets the framework surface stripped expressive tags (e.g. as ``lk.transcription``
attributes for the frontend) instead of discarding them. Returns ``[]`` for
providers without markup support.
"""
return split_markup(provider, text)[1]
# Union of every provider's XML tag names — used by the transcript sinks to strip markup
# without knowing which provider produced it (see :class:`TranscriptMarkupStripper`).
_ALL_MARKUP_TAGS: list[str] = sorted({tag for tags, _ in _PROVIDER_MARKUP.values() for tag in tags})
def split_all_markup(text: str) -> tuple[str, list[ExpressiveTag]]:
"""Strip the union of every provider's expressive markup (provider-agnostic).
The transcript sinks strip downstream, where the originating TTS/provider is no
longer in scope, so they remove every provider's tags (XML + square brackets) at
once. These tag shapes never appear in real spoken text — the LLM only emits them
as audio directives — so a universal strip is safe.
"""
text, expr_tags = _split_expr(text)
clean, raw_tags = extract_and_strip(text, xml_tags=_ALL_MARKUP_TAGS, brackets=True)
return clean, expr_tags + [{"type": tag, "value": value} for tag, value in raw_tags]
def strip_all_markup(text: str) -> str:
""":func:`split_all_markup` returning only the clean text (tags discarded)."""
return split_all_markup(text)[0]
def strip_expr_markup(text: str) -> str:
"""Strip only the ``<expr/>`` dialect, leaving all other markup untouched.
Unlike :func:`strip_all_markup`, provider-native tags and square-bracket spans
survive.
"""
return _split_expr(text)[0]
def expression_attribute(tags: list[ExpressiveTag]) -> dict[str, str] | None:
"""Build the ``lk.expression`` transcription attribute from stripped markup tags.
Surfaces a segment's leading delivery/emotion (``expression`` for Inworld/xAI,
``emotion`` for Cartesia) as ``{"value": ...}`` so the frontend can react to it.
Returns ``None`` when no such tag was present.
"""
expression = next((t["value"] for t in tags if t["type"] in ("expression", "emotion")), None)
if expression is None:
return None
return {
ATTRIBUTE_TRANSCRIPTION_EXPRESSION: json.dumps({"value": expression}, separators=(",", ":"))
}
class TranscriptMarkupStripper:
"""Stateful, provider-agnostic markup stripper for one transcript segment.
Fed text chunk-by-chunk, it returns the user-visible text and accumulates the
stripped tags. A tag-shaped trailing fragment (a partial ``<...`` or ``[...``
arriving split across chunks) is held back until it closes, so a tag straddling a
chunk boundary is never emitted half-stripped. Shared by the transcript sinks (room
output + transcript synchronizer) so stripping and expression extraction stay
identical across them.
"""
def __init__(self) -> None:
self._buf = ""
self._tags: list[ExpressiveTag] = []
def _has_open_tag(self) -> bool:
# hold a tag-shaped trailing "<" (partial XML tag) so "3 < 5" isn't stalled, and
# any unclosed "[" (bracket tags have no such ambiguity)
last_lt = self._buf.rfind("<")
if last_lt > self._buf.rfind(">"):
nxt = self._buf[last_lt + 1 : last_lt + 2]
if not nxt or nxt == "/" or nxt.isalpha():
return True
return self._buf.rfind("[") > self._buf.rfind("]")
def push(self, text: str) -> str:
"""Feed a chunk; return the clean text ready to emit (may be empty)."""
self._buf += text
if self._has_open_tag():
return ""
clean, tags = split_all_markup(self._buf)
self._buf = ""
self._tags.extend(tags)
return clean
def flush(self) -> str:
"""Drain any buffered text at segment end; return the remaining clean text."""
if not self._buf:
return ""
clean, tags = split_all_markup(self._buf)
self._buf = ""
self._tags.extend(tags)
return clean
@property
def tags(self) -> list[ExpressiveTag]:
"""The markup tags stripped so far, in document order."""
return self._tags
def expression_attribute(self) -> dict[str, str] | None:
"""The ``lk.expression`` attribute for the tags stripped so far, if any."""
return expression_attribute(self._tags)
_SELF_CLOSING_TAGS: dict[str, list[str]] = {
"cartesia": ["emotion", "speed", "volume", "break"],
"inworld": ["expression", "sound", "break"],
}
def normalize_markup(provider: str, text: str) -> str:
"""Fix common LLM markup mistakes for a provider.
Closes opening tags that should be self-closing (e.g. the LLM writes
``<expression value="happy">`` instead of ``<expression value="happy"/>`` — or
``<expr type="sound" label="laugh">`` instead of ``<expr type="sound" label="laugh"/>``).
"""
if provider in _PROVIDER_MARKUP:
text = _EXPR_UNCLOSED_RE.sub(r"\1/>", text)
tags = _SELF_CLOSING_TAGS.get(provider)
if not tags:
return text
pattern = "|".join(re.escape(t) for t in tags)
return re.sub(rf"<({pattern})\b([^>]*[^/])\s*>", r"<\1\2/>", text)
def convert_markup(provider: str, text: str) -> str:
"""Convert framework-standard markup to a provider's native syntax."""
if provider in _PROVIDER_MARKUP:
# lower expr markers first; the per-provider conversions below then
# handle the intermediate framework-standard tags they produce
text = _convert_expr(provider, text)
if provider in ("inworld", "xai"):
# <sound value="X"/> -> [X] (and <expression value="X"/> -> [X]); for xAI this
# turns inline sounds into its native brackets while emotion/prosody stay <..>
text = convert_expression_tags(text)
if provider == "xai":
# xAI has no <break>; map it to its native [pause]/[long-pause]
text = _XAI_BREAK_RE.sub(_xai_break_to_bracket, text)
# <break> is otherwise passed through unchanged: Inworld accepts it as native SSML.
return text