Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions livekit-agents/livekit/agents/tts/_provider_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -523,8 +523,9 @@ def _xai_break_to_bracket(match: re.Match[str]) -> str:
# 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/pauses ([sigh], [chuckle], [tsk], [lip-smack], [pause], ...). Keyed
# by (provider, preset) in the registry in `voice/presets.py`; self-contained.
# plus inline sounds/pauses written as XML (<sound value="sigh"/>, <sound value="chuckle"/>,
# <break time="500ms"/>, ...) and converted to xAI's native brackets in convert_markup.
# Keyed by (provider, preset) in the registry in `voice/presets.py`; self-contained.

_XAI_CUSTOMER_SERVICE: ExpressiveOptions = {
"tts_instructions_template": Instructions(
Expand All @@ -544,10 +545,10 @@ def _xai_break_to_bracket(match: re.Match[str]) -> str:
"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 — <soft>lower the volume</soft> with <lower-pitch>a settled pitch</lower-pitch>, "
"or <whisper>go quieter still</whisper> for the hardest part — then give a brief [pause] "
"after hard information so it can land. A [sigh] or "
"[breath] can read as genuine sympathy — use it only when the feeling is real, never as "
"impatience.\n"
"or <whisper>go quieter still</whisper> for the hardest part — then give a brief "

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is a valid tag we support?

'<break time="500ms"/> after hard information so it can land. A <sound value="sigh"/> or '
'<sound value="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 <slow>...</slow> so the customer can catch and note it, and read "
"codes character by character (spelled out with spaces) so each one lands.\n"
Expand Down Expand Up @@ -584,13 +585,16 @@ def _xai_break_to_bracket(match: re.Match[str]) -> str:
"<build-intensity>wait wait wait</build-intensity> (ramping up). Come back down after a "
"big moment with <decrease-intensity>...</decrease-intensity>.\n"
"- Let real feeling also land through inline sounds — motivated, not reflexive, so most turns "
"have none: [chuckle] or [giggle] at something genuinely funny (keep a full [laugh] rare), "
"[sigh] when commiserating, a quick [breath] or [inhale] before a big reaction, [tsk] for "
"mock-disapproval or 'aw man', a [lip-smack] or [tongue-click] as a tiny beat of thought, "
"[hum-tune] when you're playful. Use <laugh-speak>...</laugh-speak> to talk through a laugh. "
'have none: <sound value="chuckle"/> or <sound value="giggle"/> at something genuinely funny '

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would a voice agent hear something genuinely funny?

'(keep a full <sound value="laugh"/> rare), <sound value="sigh"/> when commiserating, a quick '
'<sound value="breath"/> or <sound value="inhale"/> before a big reaction, <sound value="tsk"/> '
"for mock-disapproval or 'aw man', a <sound value=\"lip-smack\"/> or "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when would a voice agent say something with mock-disapproval?

'<sound value="tongue-click"/> as a tiny beat of thought, <sound value="hum-tune"/> when '
"you're playful. Use <laugh-speak>...</laugh-speak> to talk through a laugh. "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would a voice agent be playful?

"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 <emphasis>...</emphasis> to punch "
"- Pace with punctuation, trailing ellipses (...) when you drift or hesitate, and an inline "
'<break time="500ms"/> for a deliberate beat. Use exclamation points for real enthusiasm, '
"and <emphasis>...</emphasis> to punch "
"a single word (e.g. that is <emphasis>so</emphasis> good) — never all-caps, which xAI "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to say "which xAI reads out"? Why would that matter to the LLM which probably has no visibility into the tts model used.

"reads out letter by letter.\n"
"- Sound like a real mouth talking: sprinkle in natural speech texture — fillers (um, uh), "
Expand Down Expand Up @@ -782,6 +786,7 @@ def expression_attribute(self) -> dict[str, str] | None:
_SELF_CLOSING_TAGS: dict[str, list[str]] = {
"cartesia": ["emotion", "speed", "volume", "break"],
"inworld": ["expression", "sound", "break"],
"xai": ["sound", "break"],
}


Expand Down
14 changes: 13 additions & 1 deletion tests/test_tokenizer_xml_markup.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,19 @@ def test_convert_inline_sounds_and_pauses_to_brackets(self) -> None:
'<sound value="laugh"/> <break time="500ms"/> <break time="2s"/> <whisper>hi</whisper>'
)
# <sound value="X"/> -> [X]; <break> -> [pause] (<1s) or [long-pause] (>=1s);
# emotion/prosody stay angle-bracketed, and normalize is a no-op for xAI
# emotion/prosody stay angle-bracketed, and normalize leaves well-formed tags alone
assert pf.convert_markup("xai", raw) == "[laugh] [pause] [long-pause] <whisper>hi</whisper>"
assert pf.normalize_markup("xai", raw) == raw
# normalize repairs a sound/break tag the LLM forgot to self-close
assert (
pf.normalize_markup("xai", '<sound value="laugh"> <break time="1s">')
== '<sound value="laugh"/> <break time="1s"/>'
)

def test_presets_registered_for_xai(self) -> None:
import re

from livekit.agents.tts import _provider_format as pf
from livekit.agents.voice import presets
from livekit.agents.voice.agent_session import DEFAULT_EXPRESSIVE_OPTIONS

Expand All @@ -199,6 +207,10 @@ def test_presets_registered_for_xai(self) -> None:
body = opts["tts_instructions_template"].common
# tuned body, not the agnostic default (which has no xai tag reference)
assert "<whisper>" in body
# presets must instruct XML only — a bracket example would teach the LLM
# to emit brackets, which the transcript stripper doesn't guard against
bracket_re = rf"\[(?:{'|'.join(pf._XAI_INLINE)}|pause|long-pause)\]"
assert not re.search(bracket_re, body)


# ===========================================================================
Expand Down
Loading