Pipecat src/pipecat/utils/context/
How Pipecat keeps what the LLM wrote, what the screen renders, and what the TTS speaks in sync — word by word, while the audio is still playing.
Learn these three colours now. They label every table, diagram and cursor for the rest of the deck.
1.1 One response, three consumers
A bot prompted to wrap cards in <card> and code in <code> produces exactly one thing:
| Consumer | Wants | Why |
|---|---|---|
| ① Conversation context | Your card is <card>1234-…-3456</card>. Run <code>npm install</code> to start. | The LLM must see its own tags next turn, or it stops producing them |
| ② The user's screen | Your card is XXXX-XXXX-XXXX-3456. + a highlighted code block | The UI renders and redacts; raw tags are noise |
| ③ The TTS provider | Your card is <spell>1234-…-3456</spell>. — and nothing for the code | Digits must be spelled out; code must not be read aloud |
1.2 What you get back
No offsets. No segment ids. No tags. Just tokens, in order, with timestamps — and they are the only signal available. Everything in this deck exists to answer three questions for each one.
Which position in the displayed text, so a UI can move a highlight.
Which span of the LLM's own text, so the tags survive the round trip.
In what order, relative to everything else in the turn.
Three questions, three layers — TextSegmentMap, WordCompletionTracker, AggregatedFrameSequencer. But first, what happened when they didn't exist.
II
Four failures, four provider quirks — and one capability that only works once they are solved.
2.1 The context drifted from what the LLM wrote
The text appended to the context was the text that came back from the TTS — so everything that made the output speakable was lost on the round trip.
Why this one is the worst: it fails silently and slowly. Nothing errors, nothing logs, and the regression shows up as "the model got lazy" several turns later.
2.2 Skipped frames arrived out of order
Configure skip_text_types=["code"] and that frame is never synthesized — no audio, no word events. So it was pushed the moment it appeared, landing before the sentence that precedes it.
LLM: "Run this:" → <code>npm install</code> → "Then reload." Context: <code>npm install</code> ← arrived first, nothing to wait for "Run this:" ← spoken later "Then reload."
The transcript reads out of order — and on interruption it is worse: text that was never spoken could still be recorded as if it had been.
2.3 Highlighting spoken words was not possible
Whole sentence frames, arriving before synthesis.
Word frames — carrying no indication of which sentence they belong to, or where inside it.
The client could not turn a stream of words into a highlight moving through a rendered sentence. There was simply no key linking the two.
2.4 RTVI bot_output_transforms were useless word-by-word
Client-side transforms operate on a segment. Receiving text word by word — with no segment identity, and no notion of "spoken so far" versus "remaining" — left nothing coherent to transform.
| Event | 1 | 2 | 3 | 4 |
|---|---|---|---|---|
| Arrives as | 1234 | 5678 | 9012 | 3456 |
| Transform sees | "1234" | "5678" | "9012" | "3456" |
| Can it redact? | no | no | no | no |
Four disconnected events, no segment, no split. To produce XXXX-XXXX-XXXX-3456 a transform needs the whole segment plus the current spoken split — which is exactly what did not exist.
2.5 Problems found along the way
| Problem | What happens |
|---|---|
| Streamed tokens | In TOKEN mode the service dispatches word-sized chunks, but tracking and progress need whole sentences — which are not known until a boundary is confirmed |
| Concurrent contexts | Two back-to-back TTSSpeakFrames on a websocket service can be in flight at once; their word streams interleave and must not consume each other's slots |
| Straddling tokens | One provider token completes one frame and starts the next (1111And), so a single event must produce output for two slots, in the right order |
| Dropped events | The provider silently never reports a word it spoke, leaving a frame — and everything queued behind it — waiting forever |
These four are why tests/test_word_completion_tracker.py is 203 tests, most of them a regression corpus of real provider behaviour.
2.6 And a feature: text transformations
TTS engines read what you give them, and written text is often not what you want spoken. $42.50 can come out "dollar forty two point five zero"; 1994 as "one thousand nine hundred ninety four" when the sentence means a year. So Pipecat ships a bundle of transforms that rewrite the text on its way to the provider — and nowhere else.
| Transform | Input | Sent to TTS |
|---|---|---|
| expand_currency | It costs $42.50 | forty-two dollars and fifty cents |
| expand_percentages | Up 12.5% | twelve point five percent |
| expand_units | 5 km away | 5 kilometers away |
| normalize_dates | on 2024-01-15 | on January 15th, two thousand… |
| email_to_speech | a@b.com | a at b dot com |
| expand_phone_numbers | 555-123-4567 | 5 5 5 1 2 3 4 5 6 7 |
| expand_numbers | room 1994 | room 1 9 9 4 |
| normalize_acronyms | I work at IBM | I work at I B M |
| strip_markdown | **bold** text | bold text |
This is not a fix for a bug — it's a way to make sure all TTS providers are going to work the same way.
expand_numbers ships off — digits are often better read as digits. You enable what your provider gets wrong, per segment type if needed: tts.add_text_transformer(fn, "credit_card").
III
The solution has two halves — and the first one is what makes the second possible.
The three texts are kept distinct as they are produced, so nothing has to be reconstructed later.
Three layers keep them aligned word by word while the TTS speaks.
Rebuilding the context from what the TTS spoke is exactly what caused 2.1. Once the three texts exist separately from the moment they are created, there is nothing to reverse-engineer — the alignment problem becomes "where are we in each", not "what did this used to say".
3.0 Every problem, and what answers it
| Problem | Solved by |
|---|---|
| 2.1 Context drift | TextSegmentMap's llm_pos cursor + WordCompletionTracker's span attribution |
| 2.2 Out-of-order skipped frames | AggregatedFrameSequencer's slot queue |
| 2.3 Word ↔ sentence correspondence | AggregatedTextProgressFrame, built from the tracker's cursors |
| 2.4 Useless RTVI transforms | segment_id + accumulated_text / remaining_text on every progress frame |
| 2.5 Streaming / concurrency / quirks | Sequencer (streaming, contexts) + tracker (straddle, drops) |
| 2.6 Text transformations — the feature | Made safe by TextSegmentMap's transformed-segment handling |
3.1 Three texts, produced at two split points
The middle channel is the AggregatedTextFrame itself — and it is never rewritten. With a SimpleTextAggregator and no transformers, all three are the same string; they diverge only where something acted.
3.2 What that looks like for one response
| # | text_type | ① raw_text | ② text | ③ tts text |
|---|---|---|---|---|
| 1 | sentence | Your card is | Your card is | Your card is |
| 2 | credit_card | <card>1234-5678-9012-3456</card> | 1234-5678-9012-3456 | <spell>1234-5678-9012-3456</spell> |
| 3 | sentence | . | . | . |
| 4 | sentence | Run | Run | Run |
| 5 | code | <code>npm install</code> | npm install | never sent — skipped |
| 6 | sentence | to start. | to start. | to start. |
Only the two tagged frames do anything interesting. Frame 2's delimiters moved into raw_text; its transformer wrapped the digits in <spell>. Frame 5 is never spoken — which is what creates the ordering problem in 2.2. text_type is the routing key throughout: it picks the transformer, decides whether the frame is spoken at all, and selects the RTVI redaction.
3.3 The guarantee that makes it useful
Exactly — character for character. Never a paraphrase, never a normalised copy, never off by a space. The two halves are always a clean split of the string the segment frame is already carrying.
That is what makes progress frames usable by anything, without coordination. A consumer holding the segment frame never has to guess how the text was transformed on its way to the TTS.
3.4 The three layers
| Layer | Scope | Question it answers |
|---|---|---|
| TextSegmentMap | One frame, segment by segment | Where in the other two texts are we, given this spoken word? |
| WordCompletionTracker | One frame | How much of this word is this frame's, which original text does it stand for, and is the frame finished? |
| AggregatedFrameSequencer | The whole turn | In what order do frames leave the TTS service? |
TTS provider ── word-timestamp events ──┐
▼
┌────────────────────────────────────────────────────────────────┐
│ AggregatedFrameSequencer one per TTS service │
│ ordered slot queue: [ spoken ][ skipped ][ spoken ] … │
│ ┌────────────────┐ ┌────────────────┐ │
│ │ WordCompletion │ │ WordCompletion │ │
│ │ Tracker │ │ Tracker │ │
│ │ ┌───────────┐ │ │ ┌───────────┐ │ │
│ │ │ TextSeg │ │ │ │ TextSeg │ │ │
│ │ │ mentMap │ │ │ │ mentMap │ │ │
│ │ └───────────┘ │ │ └───────────┘ │ │
│ └────────────────┘ └────────────────┘ │
└────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
TTSTextFrame AggregatedTextProgressFrame
→ conversation context → RTVI → the client
IV
The bottom layer, and the only one that touches the texts. One spoken word arrives — TextSegmentMap says where that leaves all three.
★ TextSegmentMap · one real cursor drives three
Only raw_pos is a real cursor. Through an unchanged segment the other two move by a count — however many letters and digits the word spent. Through a rewritten one they are held, then jump to the end of the original span in one step, because no position halfway through $42.50 means anything. Nothing ever speaks a </card>, so the walk ends on a beat that is not a word at all: once the frame is done, the tracker claims whatever llm text has left — which is how a closing tag reaches the conversation context. (↑ / ↓ also step.)
4.1 Building the map
difflib.SequenceMatcher compares TTS text against segment text, tokenized on whitespace keeping the whitespace as tokens so offsets stay exact. Each opcode becomes one TextSegment.
| Opcode | Meaning |
|---|---|
| equal | Identical on both sides — the common case |
| replace | Rewritten — every value transform and added tag |
| delete | In the original, not in the TTS text |
| insert | In the TTS text, not in the original |
All four occur and the code treats them the same way — none is a special case.
original = 'Your balance is $42.50' tts = 'Your balance is forty-two dollars and fifty cents' equal 'Your balance is ' replace '$42.50' → 'forty-two dollars…' TextSegment( original='$42.50', tts='forty-two dollars…', original_start=16, original_end=22)
4.2 is_transformed · split_markup_runs
$42.50 vs forty-two…Rule three is why <phoneme…>Siobhan</phoneme> counts: the raw cursor must cross tag characters the other texts do not have.
split_markup_runs("I love to count <spell>1234</spell>.") # → ["I love to count ", # "<spell>1234</spell>."]
| "I love to count " | plain — cursors move word by word |
| "<spell>1234</spell>." | all-or-nothing — lands as one |
Only equal opcodes can be split this way — both sides hold the same text, so a single offset cuts both. Where the sides differ there is no shared offset.
4.3 advance_by_alnums
Nothing decides which words own which tags. One loop counts letters and digits and steps over everything else — where the tags land simply follows from that.
A <...> tag is crossed for free, without using up any of the count — so an opening tag travels with the word that follows it.
llm: Your card is <card>4111</card> ↑ costs 0 ↑ 4 digits = 4 llm_pos = 23, not 17
Punctuation right after the count runs out is taken too — so a trailing comma travels with the word before it.
provider reports: "Yeah" context records: "Yeah,"
The closing tag is the one thing the loop deliberately stops short of — it is swept up by WordCompletionTracker when the frame completes. Why no diff for llm_text? It holds the same letters and digits as the segment text, in the same order, differing only in what wraps them. So the map never finds positions in it — it only counts.
4.4 _classify_hop
| Provider behaviour | Remaining | Token | Strategy |
|---|---|---|---|
| Plain word | hello world | hello | 1 literal |
| Carries its own space (Inworld) | " world" | " world" | 1 literal |
| Skipped punctuation it didn't speak | ", I can help" | I | 1 skip offset |
| Added a terminal period | account and more | account. | 1 trim |
| Lowercased the word | SQL is great | sql | 2 folded |
| Stripped a diacritic | café open | cafe | 2 folded |
| Normalized an apostrophe | don’t worry | don't | 2 folded |
| Reported without its tags | <spell>1234</spell> | 1234 | 3 markup |
| Straddles the frame boundary | 1111 | 1111And | 1 → CROSSES |
| Foreign token (dropped event) | hello world | goodbye | none |
Character for character, at three skip offsets: as-is, past whitespace, past all punctuation.
Case, accents, typography. Folding is 1:1 on length, so an offset found in the folded copy is valid in the original. Requires a word boundary — otherwise account matches Accountant.
Tags removed from both sides, then the offset translated back.
The whole thing is stateless — worked out fresh on every call, with no tag parsing and nothing remembered between words.
4.5 The four outcomes
| Outcome | Meaning | Effect |
|---|---|---|
| PLACED | Token fits inside this segment | Move to the end of the match, stop |
| CROSSES | This segment holds only the start of the token | Finish the segment, carry the rest of the token onward |
| EXHAUSTED | Nothing here can be spoken | Finish the segment, carry the whole token onward |
| NO_MATCH | Token does not belong here | Step past leading punctuation, stop |
tts='Hello five' original='Hello 5' token 'Hello five' ├─ seg0: CROSSES, 6 chars → completes └─ seg1: PLACED → user_facing_pos 7 # one token completed two segments and # crossed a transform, in one call
A token with no letters or digits — an emoji, or a symbol the provider swapped (ElevenLabs reports → as -) — cannot be matched by any strategy.
It passes the dry run via _symbol_belongs_here so the slot is not force-completed over it, then classifies NO_MATCH: the raw cursor nudges past ' → ' so the next token isn't blocked, while the segment cursor holds. Accepted without being placed.
V
A position is not a decision. WordCompletionTracker decides what text should be emitted; AggregatedFrameSequencer decides what means for the turn.
5.1 WordCompletionTracker
One call in, several answers out — each read back by the sequencer to build the frames it pushes.
| Accessor | Used for | On the map |
|---|---|---|
| word_belongs_here() | Decide whether the provider dropped an event, and this slot must be force-completed | word_belongs_current_segment() |
| add_word_and_check_complete() | Advance, and learn whether the slot is now done | advance_word() |
| get_word_for_frame() | The text of the emitted TTSTextFrame | last_leading_duplicate · last_overflow |
| get_llm_consumed() | The raw_text of that frame — what the conversation context records | llm_pos · last_completed_segment |
| suppress_in_context() | Sets append_to_context=False and skips the progress frame | in_transformed_segment |
| get_overflow_word() | Re-entered as a new word against the next slot | last_overflow |
| get_accumulated_user_facing_text() | accumulated_text → the spoken part of the segment | user_facing_pos |
| get_remaining_user_facing_text(strip=False) | remaining_text → the unspoken part | user_facing_pos |
| get_remaining_tts_text() | Text of the catch-up frame when a provider goes silent | raw_pos |
A single process_word call reads six of these to build one TTSTextFrame plus one AggregatedTextProgressFrame. Note the right-hand column: only add_word_and_check_complete() ever calls advance_word() — one entry point moves the map, and every other accessor just reads the state that call left behind. And is_complete is delegated, not tracked — forwarded to the segment map, except on a force-completed slot where the map was deliberately never advanced.
5.2 Span attribution
| word | complete | for frame | llm consumed |
|---|---|---|---|
| Your | False | Your | Your |
| card | False | card | card |
| is | False | is | is |
| 4111 | False | 4111 | <card>4111 |
| 1111 | False | 1111 | 1111 |
| thanks | True | thanks | </card> thanks |
The context is rebuilt from the right-hand column, so it stores <card>4111 1111</card> — not the bare digits.
| word | for frame | llm consumed | suppress |
|---|---|---|---|
| is | is | is | False |
| forty-two | forty-two | None | True |
| dollars | dollars | None | True |
| and | and | None | True |
| fifty | fifty | None | True |
| cents | cents | $42.50 | False |
The words are still emitted — the UI needs them to highlight — but only the last one writes to the context, and it writes $42.50.
5.3 When providers misbehave
frame "Hello there world", next frame "Goodbye"
the provider reports "Hello", never reports "there world", and the next thing to arrive is the following frame's first word
| word | belongs | complete | for frame | overflow |
|---|---|---|---|---|
| Hello | True | False | Hello | None |
| Goodbye | False | True | there world | Goodbye |
The unspoken remainder is still emitted, so the context keeps the full sentence; the foreign word is handed back for the next slot.
frame "Your card is 1111", next frame "And that's it."
the provider merges across the boundary into one token
| word | complete | for frame | overflow |
|---|---|---|---|
| Your … card | False | … | None |
| is | False | is | None |
| 1111And | True | 1111 | And |
5.4 AggregatedFrameSequencer
| Slot at head | Action |
|---|---|
| Spoken and complete | Pop it, keep walking |
| Skipped | Emit it, pop it, keep walking |
| Spoken and not complete | Stop |
That single rule is the whole ordering guarantee. On interruption clear() drops the queue, so a skipped frame whose preceding sentence was never finished is never recorded either.
| call | frames returned |
|---|---|
| process_word("Here") | TTSText, Progress |
| process_word("is") | TTSText, Progress |
| process_word("the") | TTSText, Progress |
| process_word("code") | TTSText, Progress, AggregatedTextFrame("npm install") |
The code block is released by the very word that completes the sentence in front of it — same call, correct position.
5.5 Concurrent contexts
Two back-to-back TTSSpeakFrames may overlap run_tts returns before synthesis finishes. Ordering still has to hold across both.
| Field | Scope | Lifetime |
|---|---|---|
| _slots | Global | The one ordered timeline across all contexts — ordering is the point, so this stays a single list |
| _context_append_to_context | Per context | Created at registration, removed by force_complete. Its presence marks the context live |
| _streaming_contexts | Per context | Only while a sentence accumulates from tokens; released by finalize |
seq.process_word("beta", pts=1000, context_id="ctxB") # → TTSTextFrame('beta') seq.process_word("alpha", pts=1000, context_id="ctxA") # → TTSTextFrame('alpha')
ctxB's word reaches ctxB's slot even though ctxA's slot sits earlier and is still incomplete. A word for a context with no live entry is dropped as stale — that is what stops timestamps delivered seconds after an interruption from bleeding into the next turn. A None context id matches any slot (legacy providers, where concurrency cannot occur).
5.6 Token mode (streaming)
In TextAggregationMode.TOKEN the service dispatches word-sized chunks — but tracking and progress need a sentence, and a boundary is only confirmed by lookahead: the first non-whitespace character of the next sentence.
| call | frames returned |
|---|---|
| register_spoken("Hi") | — nothing |
| register_spoken(" there!") | — boundary not yet confirmed |
| register_spoken(" Bye") | AggregatedTextFrame('Hi there!') |
| process_word("Hi") | TTSText, Progress |
| finalize("ctx1") | AggregatedTextFrame(' Bye') |
finalize is what rescues a response that ends without terminal punctuation.
Because promotion lags, a word can arrive before its slot exists. It is parked in _buffered_words, then replayed on the next promotion. _drain_buffered_words snapshots and clears before replaying, so a word that still matches nothing is re-buffered rather than looping.
VI
Where the three layers are wired together, what a client builds on top of them, and where to look in the code.
6.1 Where they're wired up
The service owns one sequencer; the sequencer builds a tracker per slot; each tracker builds one segment map. src/pipecat/services/tts_service.py
| TTSService | Sequencer method | When |
|---|---|---|
| _handle_aggregated_text | register_spoken | A frame is dispatched to the TTS |
| _handle_aggregated_text | register_skipped | A frame bypasses TTS (e.g. a code block) |
| _add_word_timestamps | process_word | A word-timestamp event arrives |
| _apply_force_complete | force_complete | An audio context ends |
| end of text input | finalize | No more tokens for this context |
| interruption | clear | The turn is cancelled |
Two ordering problems stay with the service, because both concern queues the sequencer cannot see: push_frame stamps the will_be_spoken anchor with max(_word_timestamp_tracker.last_pts, clock.now()) so it rides the clock queue alongside its progress frames; and _push_sequencer_frames routes sequencer output through the audio-context queue when streaming, so a single consumer emits anchors, words and audio in order.
6.2 One sentence, end to end
the LLM wrote "Your card is <card>4111</card>" · the aggregator split the delimiters into raw_text, so "Your card is 4111" is what reaches the TTS
Captured from a real run. Note word_belongs_here runs twice per word — once for the sequencer to pick the slot, once inside add_word_and_check_complete to decide force-completion — and that advance_word is the only call that moves a cursor. Everything after it reads back what it left. (↑ / ↓ also step.)
6.3 code-helper
# 1. Aggregate tagged segments separately llm_text_aggregator.add_pattern( type="credit_card", start_pattern="<card>", end_pattern="</card>", action=MatchAction.AGGREGATE) # 2. Never speak code blocks tts = CartesiaTTSService(..., skip_text_types=["code"]) # 3. Rewrite what the TTS receives, per segment type tts.add_text_transformer(spell_out_text, "credit_card") tts.add_text_transformer(strip_url_protocol, "link") # 4. Redact what the client renders, per segment type rtvi_observer_params=RTVIObserverParams( bot_output_transforms=[("credit_card", obfuscate_credit_card)])
| Channel | Text |
|---|---|
| LLM produced | Your card is <card>1234-…-3456</card> |
| TTS received | Your card is <spell>1234-…-3456</spell> |
| Context stored | Your card is <card>1234-…-3456</card> |
| User saw | Your card is XXXX-XXXX-XXXX-3456 bolded word by word |
const cur = this.botSpans[data.segment_id];
cur.innerHTML =
`<strong>${acc}</strong>${rem}`;
~10 lines of rendering logic, because the hard part is already done server-side.
And the code block, never spoken, still lands in the transcript after the sentence that precedes it — because it waited its turn in the slot queue.
6.4 Where to look
| utils/context/text_segment_map.py | 881 |
| utils/context/word_completion_tracker.py | 376 |
| utils/context/aggregated_frame_sequencer.py | 882 |
| utils/text/markup_utils.py | 145 |
| utils/text/alnum_utils.py | — |
| services/tts_service.py | 2076 |
| test_word_completion_tracker.py | 203 | Completion, attribution, provider quirks |
| test_aggregated_frame_sequencer.py | 134 | Slot ordering, streaming, contexts |
| test_text_segment_map.py | 54 | Alignment, markup, hop classification |
| test_tts_frame_ordering.py | 43 | End-to-end through real services |
| test_markup_utils.py | 20 | The shared helpers |
| test_cartesia_tts.py | 13 | Cartesia timestamp shapes |
| test_soniox_tts.py | 11 | Soniox timestamp shapes |
uv run pytest tests/test_text_segment_map.py tests/test_word_completion_tracker.py \ tests/test_aggregated_frame_sequencer.py tests/test_tts_frame_ordering.py \ tests/test_cartesia_tts.py tests/test_soniox_tts.py
Full write-ups in docs/architecture/ — one document per layer, plus RTVI integration and known rough edges.