Skip to content

feat(tts): unified <expr/> expression marker dialect + transcript pacing fix#1993

Open
toubatbrian wants to merge 4 commits into
brian/expressive-modefrom
claude/new-session-r2y2aw
Open

feat(tts): unified <expr/> expression marker dialect + transcript pacing fix#1993
toubatbrian wants to merge 4 commits into
brian/expressive-modefrom
claude/new-session-r2y2aw

Conversation

@toubatbrian

@toubatbrian toubatbrian commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Description

Stacked on #1982 (expressive mode). Ports two follow-up PRs from the Python repo:

expr marker dialect (#6347): replaces the provider-native markup dialects with a single marker tag — the only dialect the LLM is ever taught, in both llmInstructions() and the expressive preset bodies:

<expr type="expression" label="say playfully"/>
<expr type="sound" label="laugh"/>
<expr type="break" label="500ms"/>
<expr type="prosody" label="whisper">wrapped words</expr>
<expr type="spell">A7X9</expr>

The marker syntax is shared, but each provider gets its own instruction block advertising only the types and label vocabularies it actually supports (Cartesia: discrete emotion vocabulary + point-control prosody + spell, no sounds; Inworld: free-form expression + its sound list + break, no prosody; xAI: its sound cues + break + wrapping prosody, no free-form expression). convertMarkup lowers expr to each provider's native syntax before synthesis and drops unsupported types — the words always survive, the marker never leaks. normalizeMarkup closes unclosed self-closing markers, and the transcript strippers remove expr in a dedicated pre-pass so the type/label pair surfaces as an ExpressiveTag. The provider-native tag tables remain solely as tolerance for hallucinated native tags.

pacing fix (#6350): a markup tag with spaces in its attributes (e.g. <expr type="expression" label="warm surprise"/>) is shredded across the synchronizer's word tokens, so the per-token splitAllMarkup never saw a complete tag and paced every fragment as if it were spoken — delaying the transcript several seconds per expressive sentence, compounding over the turn. Each segment now gets a stateful TranscriptMarkupStripper (already used by the room output for the same cross-chunk problem) so tag fragments are held until the tag completes and contribute zero hyphens to pacing. Raw tokens are still forwarded unchanged for downstream stripping and lk.expression extraction.

Changes Made

  • agents/src/tts/_provider_format.ts: per-provider expr instruction blocks replace the native dialects; six preset bodies rewritten in expr; splitExpr/convertExpr pre-passes wired into splitMarkup/splitAllMarkup/normalizeMarkup/convertMarkup
  • agents/src/voice/transcription/synchronizer.ts: stateful TranscriptMarkupStripper for pacing, fed the raw pushedText slices so attribute whitespace survives token shredding (JS word tokens don't retain format, unlike Python's retain_format=True); SegmentSynchronizerImpl exported as @internal for the regression test
  • agents/src/tts/expr_markup.test.ts: port of tests/test_expr_markup.py (33 tests) plus tests for the review fixes below
  • agents/src/voice/transcription/synchronizer_markup.test.ts: port of tests/test_transcript_sync_markup.py — fails at ~13s / passes at ~2.6s with the fix
  • agents/src/tts/markup_utils.test.ts: xAI assertions updated to the expr dialect (mirrors upstream test_tokenizer_xml_markup.py updates)
  • examples/src/expressive_agent.ts: explicit-dispatch harness agent (expressive-agent-js) for cue-cli voice-mode e2e

All instruction and preset strings were verified byte-for-byte identical to the merged Python sources.

Review follow-ups

Fixed — intentional deviations from Python (bugs verified against livekit/agents main; candidates for upstreaming). Public function signatures are unchanged across frameworks:

  • normalizeMarkup('cartesia', ...) also closes an unclosed prosody marker (<expr type="prosody" label="slow">.../>): Cartesia prosody is a self-closing point control, and the missing-slash form was silently dropped by the stray-tag cleanup instead of lowering to <speed>/<volume>. xAI's wrapping prosody is untouched.
  • splitMarkup/splitAllMarkup merge expr tags and tolerated native/bracket tags by their position in the original text instead of appending all expr tags first, so a segment opening with a hallucinated native tag no longer publishes the wrong leading expression via lk.expression.

Pre-Review Checklist

  • Build passes: All builds (lint, typecheck, tests) pass locally
  • AI-generated code reviewed: Removed unnecessary comments and ensured code quality
  • Changes explained: All changes are properly documented and justified above
  • Scope appropriate: All changes relate to the PR title, or explanations provided for why they're included
  • Video demo: N/A — verified via unit tests + cue-cli voice-mode e2e with session recordings

Testing

  • Automated tests added/updated (if applicable)
  • All tests pass (pnpm build, pnpm test, pnpm lint, pnpm format:check — the only failures are pre-existing, environment-dependent suites that also fail on the base branch: example agents/cerebras needing API keys, silero model download, and the pre-existing api:check toolchain issue)
  • cue-cli voice-mode e2e (expressive-agent-js, TCP transport, inference gateway deepgram/nova-3 + openai/gpt-4.1-mini + inworld/inworld-tts-2, presets.CASUAL):
    • greeting, expressive turn, and getWeather tool turn all resolved
    • assistant chat context carries the expr dialect (<expr type="expression" label="speak with bright energy, fast and warm"/> ... <expr type="sound" label="laugh"/>) — labels with spaces, the exact shape the pacing fix targets
    • no legacy provider-native dialect (<expression value=/<emotion value=) in any turn
    • markers never reached the audio: agent-channel speech duration matches the visible words (8.8s for a ~27-word reply; vocalized tags would have added ~6–8s)
    • one transient inference.LLM APITimeoutError mid-session; retried and recovered (infra blip, unrelated to this change)
  • Make sure both restaurant_agent.ts and realtime_agent.ts work properly (for major changes)

Additional Notes

The pacing regression test drives SegmentSynchronizerImpl directly (like the Python test drives _SegmentSynchronizerImpl); since JS paces by wall-clock hyphens rather than a speaking-rate detector, the test asserts the drain time against the visible-word budget instead of an audio duration. The room transcription pacing path itself isn't reachable over the cue-cli TCP transport, so #6350's behavior is covered by the deterministic regression test.

…ing fix

Port of livekit/agents#6347 and livekit/agents#6350 on top of the
expressive-mode port (#1982).

expr marker dialect (#6347): replace the provider-native markup dialects
with a single marker tag the LLM is taught everywhere —
<expr type="expression|break|sound|prosody|spell" label="..."/>. Shared
syntax, per-provider instruction blocks advertising only the types and
label vocabularies each provider supports. convertMarkup lowers expr to
native syntax before synthesis (dropping unsupported types while keeping
the words); normalizeMarkup closes unclosed self-closing markers; the
transcript strippers remove expr in a dedicated pre-pass so type/label
surfaces as an ExpressiveTag. The six preset bodies are rewritten in expr;
native tag tables remain solely as tolerance for hallucinated native tags.

pacing fix (#6350): a markup tag with spaces in its attributes is shredded
across the synchronizer's word tokens, so the per-token splitAllMarkup
never saw a complete tag and paced every fragment as if spoken — delaying
the transcript seconds per expressive sentence. Give each segment a
stateful TranscriptMarkupStripper (fed the raw pushedText slices so
attribute whitespace survives) so tag fragments contribute zero hyphens
to pacing while raw tokens are still forwarded unchanged downstream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015im83A991642dZS2Ri9iv1
@toubatbrian toubatbrian requested a review from a team as a code owner July 8, 2026 08:26
@changeset-bot

changeset-bot Bot commented Jul 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b24acca

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 36 packages
Name Type
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-anthropic Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-cartesia Patch
@livekit/agents-plugin-cerebras Patch
@livekit/agents-plugin-deepgram Patch
@livekit/agents-plugin-did Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hedra Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-liveavatar Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-minimax Patch
@livekit/agents-plugin-mistral Patch
@livekit/agents-plugin-mistralai Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-perplexity Patch
@livekit/agents-plugin-phonic Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-runway Patch
@livekit/agents-plugin-sarvam Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugin-soniox Patch
@livekit/agents-plugin-tavus Patch
@livekit/agents-plugins-test Patch
@livekit/agents-plugin-trugen Patch
@livekit/agents-plugin-xai Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 1 potential issue.

Open in Devin Review

Comment on lines +145 to +146
/** @internal Exported for testing purposes. */
export class SegmentSynchronizerImpl {

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.

🔍 SegmentSynchronizerImpl is now exported — public API surface change

The class SegmentSynchronizerImpl was changed from a file-private class to an exported one (agents/src/voice/transcription/synchronizer.ts:146) with the @internal JSDoc tag. This is done to enable the new test file (synchronizer_markup.test.ts) to directly instantiate it. While marked @internal, if this package uses API Extractor, this export may need to be reflected in the API declarations (pnpm api:update). The defaultTextSyncOptions export at line 577 is also new.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No API surface change: voice/transcription/index.ts only re-exports _utils and textTransforms, so synchronizer.js module exports (including SegmentSynchronizerImpl) never reach the package entry point — same pattern as the existing SpeakingRateData test export in this file. defaultTextSyncOptions is not new in this PR; it exists on the base branch. api:check output is unchanged.


Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6f1c0de71b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// a wrapping marker (prosody/spell) and its span; non-greedy, instructed not to nest
const EXPR_WRAP_RE = /<expr\b(?=[^>]*type="(?:prosody|spell)")([^>]*?)>([\s\S]*?)<\/expr\s*>/g;
// a non-wrapping type the LLM forgot to self-close (normalizeMarkup fixes these)
const EXPR_UNCLOSED_RE = /(<expr\b(?=[^>]*type="(?:expression|break|sound)")[^>]*[^/>\s])\s*>/g;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Normalize Cartesia prosody markers missing the slash

For Cartesia, prosody is advertised above as a self-closing marker, but this normalization regex only fixes expression, break, and sound. If the model makes the same missing-slash mistake for Cartesia prosody, e.g. <expr type="prosody" label="slow"> One moment, normalizeMarkup('cartesia', ...) leaves it as an unpaired opening tag and convertExpr later removes it in the stray-tag cleanup, so the intended speed/volume control never reaches Cartesia. The self-closing normalization needs to cover Cartesia prosody without changing xAI's wrapping prosody behavior.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 308775a. normalizeMarkup('cartesia', ...) now also closes an unclosed prosody marker (prosody is a self-closing point control there), so <expr type="prosody" label="slow"> One moment lowers to <speed ratio="0.85"/> One moment instead of being dropped by the stray-tag cleanup. xAI keeps the narrower regex since its prosody legitimately wraps. Test added in expr_markup.test.ts.

Note: this behavior matches the merged Python implementation (_EXPR_UNCLOSED_RE covers only expression/break/sound there too), so the same fix is a candidate for upstreaming to livekit/agents.


Generated by Claude Code

Comment thread agents/src/tts/_provider_format.ts Outdated
return [clean, rawTags.map(([tag, value]) => ({ type: tag, value }))];
const [exprClean, exprTags] = splitExpr(text);
const [clean, rawTags] = extractAndStrip(exprClean, { xmlTags: ALL_MARKUP_TAGS, brackets: true });
return [clean, [...exprTags, ...rawTags.map(([tag, value]) => ({ type: tag, value }))]];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve tag order when mixing expr and native markup

When a transcript contains both expr and tolerated native markup, this appends every expr tag before every native/bracket tag instead of preserving document order. For example, <emotion value="sad"/><expr type="expression" label="happy"/>Hi yields tags in the order happy then sad; RoomIO then calls expressionAttribute(tags) and publishes lk.expression for happy even though the leading tag was sad. This matters in the native-hallucination path this commit keeps supporting, so the two stripping passes need to merge tags by their original offsets.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 308775a. splitExpr now records each marker's offset and removed spans, extractAndStrip optionally reports match offsets, and splitMarkup/splitAllMarkup merge the two passes by position in the original text. Your example now yields [sad, happy] and lk.expression publishes sad. Offsets for tags exposed by unwrapping nested hallucinated markup (fixed-point passes) are approximate but order-monotonic in practice. Tests added in expr_markup.test.ts.

Note: the merged Python implementation has the same ordering behavior (expr_tags + raw_tags), so this fix is a candidate for upstreaming to livekit/agents.


Generated by Claude Code

claude added 3 commits July 9, 2026 09:01
…ent order

Two review findings, both also present in the Python source (candidates for
upstreaming to livekit/agents):

- normalizeMarkup only fixed unclosed expression/break/sound markers. For
  Cartesia, prosody is a self-closing point control, so a missing slash left
  an unpaired opening tag that convertExpr's stray-tag cleanup then dropped —
  the speed/volume control never reached the TTS. Include prosody in the
  unclosed fix for Cartesia only; xAI's wrapping prosody stays an opening tag.

- splitMarkup/splitAllMarkup appended every expr tag before every native or
  bracket tag, so a segment opening with a hallucinated native tag published
  the wrong leading expression via lk.expression. splitExpr now records each
  marker's offset and removed spans, extractAndStrip optionally reports match
  offsets, and the two passes merge by position in the original text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015im83A991642dZS2Ri9iv1
Explicit-dispatch example agent (agentName: expressive-agent-js) wiring
inference STT/LLM with inworld TTS and the CASUAL expressive preset, for
driving voice-mode e2e assertions against the expr marker dialect with
cue-cli.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015im83A991642dZS2Ri9iv1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants