Skip to content

Commit 0a4c613

Browse files
committed
fix(expressive): correctness and pacing fixes for transcript markup stripping
Addresses the issues found reviewing this branch: - room output: rotate segments by opening the replacement writer before closing the current one, so a failed stream_text no longer strands self._writer on a closed writer and drops the rest of the turn. - room output: record _latest_text before the isconnected() check, so a brief disconnect no longer finalizes the turn with an earlier chunk. - room output: lk.expression rides the opening header only, matching the ATTRIBUTE_TRANSCRIPTION_EXPRESSION docstring (drops the closing-header fallback that contradicted it). - markup: vanish_trail now sees the character preceding a tag in the OUTPUT (new scan_and_replace helper plus a prev_char threaded through split_all_markup/_split_expr/extract_and_strip). Fixes words gluing across chunk boundaries (Ready.Go) and stacked markers leaving a doubled space. - markup: _emit strips only spaces/tabs, so newlines and indentation straddling a chunk boundary survive in every delta transcript. - markup: the stripper holds only the partial tag, not the text in front of it, so words spoken before a marker stay in the current segment and a tag-shaped angle bracket no longer stalls the turn until flush. - markup: strip_all_markup takes an opt-in drop_open_tail. It was applied to every input, truncating complete text in final transcripts and in stored chat history. - synchronizer: _visible_hyphens advances a cursor instead of re-stripping and re-hyphenating the whole prefix twice per word. An 800-word turn drops from 4.40s to 0.0145s of blocking CPU with identical counts, and no longer needs prefix truncation. Adds 19 tests, including a property test asserting streaming output matches whole-text stripping at every 2- and 3-way split.
1 parent 7c8d07f commit 0a4c613

5 files changed

Lines changed: 273 additions & 62 deletions

File tree

livekit-agents/livekit/agents/tts/_provider_format.py

Lines changed: 52 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@
2121
from typing import TYPE_CHECKING, TypedDict
2222

2323
from ..types import ATTRIBUTE_TRANSCRIPTION_EXPRESSION, TimedString
24-
from .markup_utils import convert_expression_tags, extract_and_strip, vanish_trail
24+
from .markup_utils import (
25+
convert_expression_tags,
26+
extract_and_strip,
27+
scan_and_replace,
28+
vanish_trail,
29+
)
2530

2631

2732
class ExpressiveTag(TypedDict):
@@ -752,7 +757,7 @@ def _expr_attrs(attrs: str) -> dict[str, str]:
752757
return dict(_EXPR_ATTR_RE.findall(attrs))
753758

754759

755-
def _split_expr(text: str) -> tuple[str, list[ExpressiveTag]]:
760+
def _split_expr(text: str, *, prev_char: str = "") -> tuple[str, list[ExpressiveTag]]:
756761
"""Strip expr markers and collect (type, label) pairs, in document order.
757762
758763
The generic ``extract_and_strip`` pass can't produce the right ExpressiveTag for
@@ -766,13 +771,18 @@ def _split_expr(text: str) -> tuple[str, list[ExpressiveTag]]:
766771

767772
tags: list[ExpressiveTag] = []
768773

769-
def _repl(m: re.Match[str]) -> str:
774+
def _repl(m: re.Match[str], before: str) -> str:
770775
attrs = _expr_attrs(m.group(1))
771776
tags.append({"type": attrs.get("type", ""), "value": attrs.get("label", "")})
772-
return vanish_trail(m, m.group("trail"))
773-
774-
clean = _EXPR_OPEN_STRIP_RE.sub(_repl, text)
775-
clean = _EXPR_CLOSE_STRIP_RE.sub(lambda m: vanish_trail(m, m.group("trail")), clean)
777+
return vanish_trail(before, m.group("trail"))
778+
779+
clean = scan_and_replace(_EXPR_OPEN_STRIP_RE, text, _repl, prev_char=prev_char)
780+
clean = scan_and_replace(
781+
_EXPR_CLOSE_STRIP_RE,
782+
clean,
783+
lambda m, before: vanish_trail(before, m.group("trail")),
784+
prev_char=prev_char,
785+
)
776786
return clean, tags
777787

778788

@@ -891,7 +901,7 @@ def llm_instructions(provider: str, steering: SpeechSteeringOptions | None = Non
891901
_ALL_MARKUP_TAGS: list[str] = sorted({tag for tags in _PROVIDER_MARKUP.values() for tag in tags})
892902

893903

894-
def split_all_markup(text: str) -> tuple[str, list[ExpressiveTag]]:
904+
def split_all_markup(text: str, *, prev_char: str = "") -> tuple[str, list[ExpressiveTag]]:
895905
"""Strip the union of every provider's expressive XML markup (provider-agnostic).
896906
897907
The transcript sinks strip downstream, where the originating TTS/provider is no
@@ -909,8 +919,8 @@ def split_all_markup(text: str) -> tuple[str, list[ExpressiveTag]]:
909919
if "<" not in text:
910920
return text, []
911921

912-
text, expr_tags = _split_expr(text)
913-
clean, raw_tags = extract_and_strip(text, xml_tags=_ALL_MARKUP_TAGS)
922+
text, expr_tags = _split_expr(text, prev_char=prev_char)
923+
clean, raw_tags = extract_and_strip(text, xml_tags=_ALL_MARKUP_TAGS, prev_char=prev_char)
914924
return clean, expr_tags + [{"type": tag, "value": value} for tag, value in raw_tags]
915925

916926

@@ -942,13 +952,19 @@ def _drop_open_tail(text: str) -> str:
942952
return text
943953

944954

945-
def strip_all_markup(text: str) -> str:
955+
def strip_all_markup(text: str, *, drop_open_tail: bool = False) -> str:
946956
""":func:`split_all_markup` returning only the clean text (tags discarded).
947957
948-
Also drops a trailing unterminated tag, so callers slicing text at arbitrary
949-
character offsets that may fall inside a tag never see a partial tag.
958+
Args:
959+
text: The text to strip.
960+
drop_open_tail: Also drop a trailing unterminated tag. For prefixes and
961+
mid-stream accumulations only — it is lossy on complete text
962+
(``"the <emotion I felt was strong"`` -> ``"the "``), so leave it off
963+
for final transcripts and stored chat history.
950964
"""
951-
return split_all_markup(_drop_open_tail(text))[0]
965+
if drop_open_tail:
966+
text = _drop_open_tail(text)
967+
return split_all_markup(text)[0]
952968

953969

954970
def strip_expr_markup(text: str) -> str:
@@ -1001,10 +1017,9 @@ class TranscriptMarkupStripper:
10011017
def __init__(self) -> None:
10021018
self._buf = ""
10031019
self._tags: list[ExpressiveTag] = []
1004-
# last emitted char was whitespace (or nothing emitted yet): a stripped tag
1005-
# often leaves its separating space in the next chunk, so lstrip the next
1006-
# emission rather than surface a doubled/leading space
1007-
self._tail_ws = True
1020+
# last character emitted ("" until the first emission), so a tag opening a chunk
1021+
# keeps the space separating it from the previous one instead of gluing words
1022+
self._last_char = ""
10081023

10091024
def _has_open_tag(self) -> bool:
10101025
# hold a trailing "<" that could still be a known tag (so "3 < 5" or "i<n then"
@@ -1013,30 +1028,37 @@ def _has_open_tag(self) -> bool:
10131028
return _open_tag_fragment(self._buf)
10141029

10151030
def _emit(self, clean: str) -> str:
1016-
if self._tail_ws:
1017-
clean = clean.lstrip()
1031+
# a stripped tag leaves its separator at the head of the next chunk. Spaces/tabs
1032+
# only: newlines and indentation are transcript content, not leftover separators
1033+
if not self._last_char or self._last_char in " \t":
1034+
clean = clean.lstrip(" \t")
10181035
if clean:
1019-
self._tail_ws = clean[-1].isspace()
1036+
self._last_char = clean[-1]
10201037
return clean
10211038

1039+
def _consume(self, upto: int | None = None) -> str:
1040+
head = self._buf if upto is None else self._buf[:upto]
1041+
self._buf = "" if upto is None else self._buf[upto:]
1042+
if not head:
1043+
return ""
1044+
clean, tags = split_all_markup(head, prev_char=self._last_char)
1045+
self._tags.extend(tags)
1046+
return self._emit(clean)
1047+
10221048
def push(self, text: str) -> str:
10231049
"""Feed a chunk; return the clean text ready to emit (may be empty)."""
10241050
self._buf += text
10251051
if self._has_open_tag():
1026-
return ""
1027-
clean, tags = split_all_markup(self._buf)
1028-
self._buf = ""
1029-
self._tags.extend(tags)
1030-
return self._emit(clean)
1052+
# hold only the partial tag: the text in front of it was spoken earlier, so it
1053+
# belongs to the current wire segment and must not stall until the tag closes
1054+
return self._consume(self._buf.rfind("<"))
1055+
return self._consume()
10311056

10321057
def flush(self) -> str:
10331058
"""Drain any buffered text at segment end; return the remaining clean text."""
10341059
if not self._buf:
10351060
return ""
1036-
clean, tags = split_all_markup(self._buf)
1037-
self._buf = ""
1038-
self._tags.extend(tags)
1039-
return self._emit(clean)
1061+
return self._consume()
10401062

10411063
@property
10421064
def tags(self) -> list[ExpressiveTag]:

livekit-agents/livekit/agents/tts/markup_utils.py

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import re
4+
from collections.abc import Callable
45

56
_EXPRESSION_RE = re.compile(r'<expression\s+value="([^"]*)"(?:\s*/>|>(?:.*?)</expression>)')
67
_SOUND_RE = re.compile(r'<sound\s+value="([^"]*)"(?:\s*/>|>(?:.*?)</sound>)')
@@ -16,19 +17,55 @@ def convert_expression_tags(text: str) -> str:
1617
_VALUE_ATTR_RE = re.compile(r'\b[\w-]+\s*=\s*"([^"]*)"')
1718

1819

19-
def vanish_trail(m: re.Match[str], trail: str) -> str:
20+
def scan_and_replace(
21+
pattern: re.Pattern[str],
22+
text: str,
23+
replace: Callable[[re.Match[str], str], str],
24+
*,
25+
prev_char: str = "",
26+
) -> str:
27+
"""``pattern.sub`` that also tells ``replace`` what character precedes each match.
28+
29+
The character is the last of the *output* so far, so back-to-back tags all see the
30+
one before the first of them. ``prev_char`` seeds it for text continuing an earlier
31+
chunk (``""`` means start of stream).
32+
"""
33+
out: list[str] = []
34+
last = prev_char
35+
pos = 0
36+
for m in pattern.finditer(text):
37+
gap = text[pos : m.start()]
38+
if gap:
39+
out.append(gap)
40+
last = gap[-1]
41+
rep = replace(m, last)
42+
if rep:
43+
out.append(rep)
44+
last = rep[-1]
45+
pos = m.end()
46+
out.append(text[pos:])
47+
return "".join(out)
48+
49+
50+
def vanish_trail(before: str, trail: str) -> str:
2051
"""Trailing spaces of a fully removed tag.
2152
22-
A tag with nothing visible before it on the line takes its trailing spaces with
23-
it (``"a <t/> b"`` -> ``"a b"``, ``"<t/> b"`` -> ``"b"``); one glued to the
24-
preceding word keeps them as the word separator (``"a.<t/> b"`` -> ``"a. b"``).
53+
A tag with nothing visible before it takes its trailing spaces with it
54+
(``"a <t/> b"`` -> ``"a b"``, ``"<t/> b"`` -> ``"b"``); one glued to the preceding
55+
word keeps them as the word separator (``"a.<t/> b"`` -> ``"a. b"``).
56+
57+
``before`` is the preceding character in the *output* (see :func:`scan_and_replace`),
58+
so stacked markers collapse to one separator and a tag opening a chunk still sees the
59+
previous chunk.
2560
"""
26-
if m.start() == 0 or m.string[m.start() - 1].isspace():
61+
if not before or before.isspace():
2762
return ""
2863
return trail
2964

3065

31-
def extract_and_strip(text: str, *, xml_tags: list[str]) -> tuple[str, list[tuple[str, str]]]:
66+
def extract_and_strip(
67+
text: str, *, xml_tags: list[str], prev_char: str = ""
68+
) -> tuple[str, list[tuple[str, str]]]:
3269
"""Strip XML markup tags and collect the stripped tags in a single pass.
3370
3471
One regex scan both removes the markup and records each removed tag, so
@@ -46,14 +83,15 @@ def extract_and_strip(text: str, *, xml_tags: list[str]) -> tuple[str, list[tupl
4683
``"A7X9"``), else its first quoted attribute value
4784
(``<emotion value="happy"/>`` -> ``"happy"``), falling back to ``""``.
4885
49-
Wrapping tags keep their inner content in ``clean_text`` (only the delimiters
50-
are removed); self-closing and lone tags are removed entirely, along with their
51-
trailing spaces where those were only separating the tag from its neighbours
52-
(see :func:`vanish_trail`).
86+
Wrapping tags keep their inner content in ``clean_text`` (only the delimiters are
87+
removed); self-closing and lone tags are removed entirely, along with any trailing
88+
spaces that were only separating them (see :func:`vanish_trail`).
5389
5490
Args:
5591
text: The text containing markup.
5692
xml_tags: XML tag names to handle (e.g. ``["emotion", "sound"]``).
93+
prev_char: Character preceding ``text`` in the stream (``""`` = start of
94+
stream), so a tag opening a chunk keeps its separator from the previous one.
5795
"""
5896
if not xml_tags:
5997
return text, []
@@ -71,7 +109,7 @@ def extract_and_strip(text: str, *, xml_tags: list[str]) -> tuple[str, list[tupl
71109
)
72110
tags: list[tuple[str, str]] = []
73111

74-
def _repl(m: re.Match[str]) -> str:
112+
def _repl(m: re.Match[str], before: str) -> str:
75113
groups = m.groupdict()
76114
trail = groups.get("trail") or ""
77115
tag = groups.get("tag")
@@ -84,9 +122,9 @@ def _repl(m: re.Match[str]) -> str:
84122
value = attr_match.group(1) if attr_match else ""
85123
tags.append((tag, value))
86124
# wrapping tags keep their inner content; self-closing/lone tags vanish
87-
return inner + trail if inner is not None else vanish_trail(m, trail)
125+
return inner + trail if inner is not None else vanish_trail(before, trail)
88126

89-
return vanish_trail(m, trail) # lone closing tag
127+
return vanish_trail(before, trail) # lone closing tag
90128

91129
# iterate to a fixed point so nested wrapping tags are fully removed: a single pass
92130
# strips only the outer tag (e.g. <excited><loud>hi</loud></excited> -> keeps the
@@ -96,5 +134,5 @@ def _repl(m: re.Match[str]) -> str:
96134
prev = None
97135
while clean != prev:
98136
prev = clean
99-
clean = pattern.sub(_repl, clean)
137+
clean = scan_and_replace(pattern, clean, _repl, prev_char=prev_char)
100138
return clean, tags

livekit-agents/livekit/agents/voice/room_io/_output.py

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ async def capture_text(self, text: str) -> None:
279279
# Stripping the whole accumulation each time avoids partial-tag edge cases; the
280280
# expression is dropped here — the deprecated rtc Transcription API has no
281281
# attribute channel (the stream-based output carries lk.expression instead).
282-
clean_text = strip_all_markup(self._pushed_text)
282+
clean_text = strip_all_markup(self._pushed_text, drop_open_tail=True)
283283
await self._publish_transcription(self._current_id, clean_text, final=False)
284284

285285
@utils.log_exceptions(logger=logger)
@@ -481,18 +481,29 @@ def _consume_expressions(self) -> None:
481481
async def _rotate_writer(self, pending: list[ExpressiveTag]) -> None:
482482
"""Finalize the current wire segment and open a new one led by the pending expression."""
483483
assert self._writer is not None
484-
attributes = {ATTRIBUTE_TRANSCRIPTION_FINAL: "true"}
485-
if self._track_id:
486-
attributes[ATTRIBUTE_TRANSCRIPTION_TRACK_ID] = self._track_id
487-
await self._writer.aclose(attributes=attributes)
484+
old_writer = self._writer
488485

486+
# open the replacement first: closing first would strand self._writer on a closed
487+
# writer if stream_text fails, silently dropping the rest of the turn
488+
prev_id = self._current_id
489489
self._current_id = utils.shortuuid("SG_")
490-
self._writer = await self._create_text_writer(
491-
extra_attributes=expression_attribute(pending)
492-
)
490+
try:
491+
new_writer = await self._create_text_writer(
492+
extra_attributes=expression_attribute(pending)
493+
)
494+
except BaseException:
495+
self._current_id = prev_id
496+
raise
497+
498+
self._writer = new_writer
493499
self._writer_expression_sent = True
494500
self._writer_has_text = False
495501

502+
attributes = {ATTRIBUTE_TRANSCRIPTION_FINAL: "true"}
503+
if self._track_id:
504+
attributes[ATTRIBUTE_TRANSCRIPTION_TRACK_ID] = self._track_id
505+
await old_writer.aclose(attributes=attributes)
506+
496507
async def _capture_delta(self, piece: str, timing_src: str) -> None:
497508
clean_text = self._stripper.push(piece)
498509
if not self._room.isconnected():
@@ -548,10 +559,14 @@ async def capture_text(self, text: str) -> None:
548559
await self._capture_delta(piece, text)
549560
else: # always create a new writer
550561
clean_text, self._segment_tags = split_all_markup(text)
551-
if not clean_text or not self._room.isconnected():
562+
if not clean_text:
552563
return
564+
# record before the connection check: flush() republishes _latest_text, so
565+
# skipping it while disconnected would finalize an earlier chunk's text
553566
payload = self._encode(clean_text, text)
554567
self._latest_text = payload
568+
if not self._room.isconnected():
569+
return
555570
tmp_writer = await self._create_text_writer(
556571
extra_attributes=expression_attribute(self._segment_tags)
557572
)
@@ -587,9 +602,7 @@ async def _flush_task(
587602
logger.warning("failed to publish agent transcription to room: %s", e)
588603

589604
def flush(self) -> None:
590-
# only emit on a segment that captured text (keeps lk.transcription cadence intact).
591-
# The closing header carries the expression only as a fallback when the stream
592-
# never got one — e.g. the tag only completed in the flush remainder.
605+
# only emit on a segment that captured text (keeps lk.transcription cadence intact)
593606
if self._participant_identity is None or not self._capturing:
594607
return
595608

@@ -600,8 +613,9 @@ def flush(self) -> None:
600613
extra_attributes: dict[str, str] | None = None
601614
if self._is_delta_stream:
602615
remaining = self._stripper.flush()
603-
if not self._writer_expression_sent:
604-
extra_attributes = expression_attribute(self._pending_expressions())
616+
# lk.expression rides the opening header only (see
617+
# ATTRIBUTE_TRANSCRIPTION_EXPRESSION); one completing in the flush remainder
618+
# has no text left to apply to
605619
else:
606620
remaining = ""
607621
extra_attributes = expression_attribute(self._segment_tags)

0 commit comments

Comments
 (0)