Pipecat src/pipecat/utils/context/

Three texts,
one cursor.

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.

① LLM TEXT — the conversation context ② SEGMENT TEXT — downstream consumers ③ TTS TEXT — the provider

Learn these three colours now. They label every table, diagram and cursor for the rest of the deck.

1.1 One response, three consumers

The same sentence has to be three different strings.

A bot prompted to wrap cards in <card> and code in <code> produces exactly one thing:

Your card is <card>1234-5678-9012-3456</card>. Run <code>npm install</code> to start.
ConsumerWantsWhy
① 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

The provider reports only the words it actually spoke.

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.

Question one

Where are we?

Which position in the displayed text, so a UI can move a highlight.

Question two

What goes in the context?

Which span of the LLM's own text, so the tags survive the round trip.

Question three

Can this frame go now?

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

What was broken

Four failures, four provider quirks — and one capability that only works once they are solved.

2.1 Context drift 2.2 Out-of-order frames 2.3 No word ↔ sentence link 2.4 Useless RTVI transforms 2.5 Provider reality 2.6 Text transformations — the feature

2.1 The context drifted from what the LLM wrote

The bot slowly forgets its own conventions.

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.

Before — context receives
Your card is 1234 5678 9012 3456
Next turn: the LLM sees no tags and stops emitting them. The feature quietly decays over a handful of turns.
Now — context receives
Your card is <card>1234-5678-9012-3456</card>
The LLM sees its own convention and keeps it.

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

A frame with no audio has nothing to wait for.

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

Two streams, no correspondence between them.

What the client renders

TextType.SENTENCE

Whole sentence frames, arriving before synthesis.

What arrives as speech progresses

TextType.WORD

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

You cannot redact a card number that arrives in four pieces.

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.

Event1234
Arrives as1234567890123456
Transform sees"1234""5678""9012""3456"
Can it redact?nononono

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

Then reality: providers, streaming, concurrency.

ProblemWhat 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

Rewritten for the provider. For nobody else.

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.

TransformInputSent to TTS
expand_currencyIt costs $42.50forty-two dollars and fifty cents
expand_percentagesUp 12.5%twelve point five percent
expand_units5 km away5 kilometers away
normalize_dateson 2024-01-15on January 15th, two thousand…
email_to_speecha@b.coma at b dot com
expand_phone_numbers555-123-45675 5 5 1 2 3 4 5 6 7
expand_numbersroom 1994room 1 9 9 4
normalize_acronymsI work at IBMI work at I B M
strip_markdown**bold** textbold text

Which ones you need depends on the provider

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.

So it's opt-in, transform by transform

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

How they're solved

The solution has two halves — and the first one is what makes the second possible.

Half one · at production time
Kept distinct, never reconstructed

The three texts are kept distinct as they are produced, so nothing has to be reconstructed later.

3.1 split points · 3.2 six frames · 3.3 the guarantee
Half two · during playback
Kept aligned, word by word

Three layers keep them aligned word by word while the TTS speaks.

3.4 the layers · the cursor · tracker · sequencer

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

The map for the rest of the deck.

ProblemSolved by
2.1 Context driftTextSegmentMap's llm_pos cursor + WordCompletionTracker's span attribution
2.2 Out-of-order skipped framesAggregatedFrameSequencer's slot queue
2.3 Word ↔ sentence correspondenceAggregatedTextProgressFrame, built from the tracker's cursors
2.4 Useless RTVI transformssegment_id + accumulated_text / remaining_text on every progress frame
2.5 Streaming / concurrency / quirksSequencer (streaming, contexts) + tracker (straddle, drops)
2.6 Text transformations — the featureMade safe by TextSegmentMap's transformed-segment handling

3.1 Three texts, produced at two split points

Nobody authors three strings. Two split points create them.

LLM tokens
▼
Split 1 — the aggregator
LLMTextProcessor · BaseTextAggregator
PatternPairAggregator / SimpleTextAggregator decide where segments end and what counts as a delimiter.
├─▶
① raw_textLLM TEXT (<card>1234-5678-9012-3456</card>) → conversation context
└─▶
② textSEGMENT TEXT (1234-5678-9012-3456) → downstream consumers (the AggregatedTextFrame)
▼
Split 2 — the TTS service
text filters · per-type transformers
Rewrite for speech only — operating on a copy on its way out.
▼
③ tts textTTS TEXT (<spell>1234-5678-9012-3456</spell>) → the provider

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

One sentence in. Six frames out.

#text_type ① raw_text ② text ③ tts text
1sentenceYour card is Your card isYour card is
2credit_card<card>1234-5678-9012-3456</card>1234-5678-9012-3456<spell>1234-5678-9012-3456</spell>
3sentence...
4sentenceRunRunRun
5code<code>npm install</code>npm installnever sent — skipped
6sentenceto 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

One rule holds after every single word.

progress.accumulated_text + progress.remaining_text  ==  AggregatedTextFrame.text

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.

  • Highlight the first half, leave the second plain → word highlighting
  • Redact both halves → redaction that keeps advancing
  • Concatenate them → the original back, exactly

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

Each owns one concern. None knows about the layer above.

LayerScopeQuestion 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 moving cursor

The bottom layer, and the only one that touches the texts. One spoken word arrives — TextSegmentMap says where that leaves all three.

★ The walk 4.1 Building the map 4.2 Transformed spans 4.3 Counting alnums 4.4 Matching a token 4.5 The four outcomes

★ TextSegmentMap · one real cursor drives three

Watch the followers hold, then jump.

③ tts text
raw_pos 0
② segment text
user_facing 0
① llm text
llm_pos 0
advance_word(—)
in_transformed_segment: false
0 / 0

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

Diff the two texts once, at word level.

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.

OpcodeMeaning
equalIdentical on both sides — the common case
replaceRewritten — every value transform and added tag
deleteIn the original, not in the TTS text
insertIn 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

All-or-nothing, and how that cost is contained.

A segment is transformed when…

  • the letters and digits differ — $42.50 vs forty-two…
  • the number of words differs
  • the TTS side contains a tag — even if the spoken words are identical

Rule three is why <phoneme…>Siobhan</phoneme> counts: the raw cursor must cross tag characters the other texts do not have.

So cut the tag out into its own segment

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

Counting is what attaches tags to words.

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.

Rule one

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

Rule two

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

Challenge: Provider tokens are messy.

Three strategies, first hit wins.

Provider behaviourRemainingTokenStrategy
Plain wordhello worldhello1 literal
Carries its own space (Inworld)" world"" world"1 literal
Skipped punctuation it didn't speak", I can help"I1 skip offset
Added a terminal periodaccount and moreaccount.1 trim
Lowercased the wordSQL is greatsql2 folded
Stripped a diacriticcafé opencafe2 folded
Normalized an apostrophedon’t worrydon't2 folded
Reported without its tags<spell>1234</spell>12343 markup
Straddles the frame boundary11111111And1 → CROSSES
Foreign token (dropped event)hello worldgoodbyenone

1 · literal

Character for character, at three skip offsets: as-is, past whitespace, past all punctuation.

2 · variation folded

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.

3 · markup stripped

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

Two end the walk. Two carry the token onward.

OutcomeMeaningEffect
PLACEDToken fits inside this segmentMove to the end of the match, stop
CROSSESThis segment holds only the start of the tokenFinish the segment, carry the rest of the token onward
EXHAUSTEDNothing here can be spokenFinish the segment, carry the whole token onward
NO_MATCHToken does not belong hereStep 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

The symbol case

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

Policy and order

A position is not a decision. WordCompletionTracker decides what text should be emitted; AggregatedFrameSequencer decides what means for the turn.

5.1 The tracker 5.2 Span attribution 5.3 Misbehaving providers 5.4 The slot queue 5.5 Concurrent contexts 5.6 Token mode

5.1 WordCompletionTracker

The map says where. The tracker decides what that means.

One call in, several answers out — each read back by the sequencer to build the frames it pushes.

AccessorUsed forOn the map
word_belongs_here()Decide whether the provider dropped an event, and this slot must be force-completedword_belongs_current_segment()
add_word_and_check_complete()Advance, and learn whether the slot is now doneadvance_word()
get_word_for_frame()The text of the emitted TTSTextFramelast_leading_duplicate · last_overflow
get_llm_consumed()The raw_text of that frame — what the conversation context recordsllm_pos · last_completed_segment
suppress_in_context()Sets append_to_context=False and skips the progress framein_transformed_segment
get_overflow_word()Re-entered as a new word against the next slotlast_overflow
get_accumulated_user_facing_text()accumulated_text → the spoken part of the segmentuser_facing_pos
get_remaining_user_facing_text(strip=False)remaining_text → the unspoken partuser_facing_pos
get_remaining_tts_text()Text of the catch-up frame when a provider goes silentraw_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

Recovering structure the TTS never saw.

Tags the provider never reported

wordcompletefor framellm consumed
YourFalseYourYour
cardFalsecardcard
isFalseisis
4111False4111<card>4111
1111False11111111
thanksTruethanks</card> thanks

The context is rebuilt from the right-hand column, so it stores <card>4111 1111</card> — not the bare digits.

Suppressing mid-transform words

wordfor framellm consumedsuppress
isisisFalse
forty-twoforty-twoNoneTrue
dollarsdollarsNoneTrue
andandNoneTrue
fiftyfiftyNoneTrue
centscents$42.50False

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

Never stall. Emit the remainder and move on.

Dropped event → force-complete

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

wordbelongscompletefor frameoverflow
HelloTrueFalseHelloNone
GoodbyeFalseTruethere worldGoodbye

The unspoken remainder is still emitted, so the context keeps the full sentence; the foreign word is handed back for the next slot.

Straddling token → split at both ends

frame "Your card is 1111", next frame "And that's it."
the provider merges across the boundary into one token

wordcompletefor frameoverflow
Your … cardFalse…None
isFalseisNone
1111AndTrue1111And

5.4 AggregatedFrameSequencer

Every frame takes a slot — spoken or not.

slot 1 · spoken
"Here is the code:"
tracker ●   complete: ✗
slot 2 · skipped
npm install
no tracker
slot 3 · spoken
"Then reload."
tracker ●   complete: ✗
Slot at headAction
Spoken and completePop it, keep walking
SkippedEmit it, pop it, keep walking
Spoken and not completeStop

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.

callframes 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 utterances in flight, one timeline.

Two back-to-back TTSSpeakFrames may overlap run_tts returns before synthesis finishes. Ordering still has to hold across both.

FieldScopeLifetime
_slotsGlobalThe one ordered timeline across all contexts — ordering is the point, so this stays a single list
_context_append_to_contextPer contextCreated at registration, removed by force_complete. Its presence marks the context live
_streaming_contextsPer contextOnly 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)

The sentence is always known one token late.

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.

callframes 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.

Buffered words

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

In practice

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 6.2 One sentence, end to end 6.3 code-helper 6.4 Where to look

6.1 Where they're wired up

All three are driven from TTSService.

The service owns one sequencer; the sequencer builds a tracker per slot; each tracker builds one segment map. src/pipecat/services/tts_service.py

TTSServiceSequencer methodWhen
_handle_aggregated_textregister_spokenA frame is dispatched to the TTS
_handle_aggregated_textregister_skippedA frame bypasses TTS (e.g. a code block)
_add_word_timestampsprocess_wordA word-timestamp event arrives
_apply_force_completeforce_completeAn audio context ends
end of text inputfinalizeNo more tokens for this context
interruptionclearThe 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

Step through every call the three layers make.

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

③ tts text
raw_pos 0
② segment text
user_facing 0
① llm text
llm_pos 0
—
0 / 0

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

Four settings. Four independent channels.

# 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)])
ChannelText
LLM producedYour card is <card>1234-…-3456</card>
TTS receivedYour card is <spell>1234-…-3456</spell>
Context storedYour card is <card>1234-…-3456</card>
User sawYour card is XXXX-XXXX-XXXX-3456
bolded word by word

The client

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

Code coverage.

Source

utils/context/text_segment_map.py881
utils/context/word_completion_tracker.py376
utils/context/aggregated_frame_sequencer.py882
utils/text/markup_utils.py145
utils/text/alnum_utils.py—
services/tts_service.py2076

Tests

test_word_completion_tracker.py203Completion, attribution, provider quirks
test_aggregated_frame_sequencer.py134Slot ordering, streaming, contexts
test_text_segment_map.py54Alignment, markup, hop classification
test_tts_frame_ordering.py43End-to-end through real services
test_markup_utils.py20The shared helpers
test_cartesia_tts.py13Cartesia timestamp shapes
test_soniox_tts.py11Soniox 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.

TTS word tracking ← → slides · ↑ ↓ step · T theme · F full 1 / 35