Skip to content
C.W.K.
Stream
Lesson 06 of 06 · published

A Chat Answer, Paragraph by Paragraph

~13 min · speakable-units, invariants, streaming, latency

Level 0Muted
0 XP0/61 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete
"Yes, let it go that way." — Dad, Málaga, 2026-10-18, asked whether her voice may start at a chat answer's first finished paragraph

The Last Unit Was the Whole Answer

The second lesson of this track gave the rule its reason: one synthesis per speakable unit, never split to start sooner, because an expressive voice carries its tone across sentences and resets at every cut. A unit ended only at a tool call, so a spoken answer with no tool was a single unit, and the voice waited for the whole of it. At home the cost hid inside the rest of the turn. On the road it did not. The first per-turn timelines from the phone (seven turns over LTE on 2026-10-18, the next lesson in Track 10 has them all) put the gap between her first text and her first sound at a median of 5.2 seconds, and 10 seconds on the afternoon's no-tool replies. The reason was plain in the timeline: a reply's last unit was its whole remaining text.

One Exception, Written Into the Invariant

So the question went to Dad in the form the invariant would need it: may her voice start at a chat answer's first finished paragraph, instead of at the end of the whole answer? He said yes, and the yes is recorded in cwkBellows' invariant 19 itself, as an exception for chat answers only. A spoken chat reply is now read paragraph by paragraph as it streams. Everything else keeps the whole unit: notices, Beacon and Studio scripts, video lines, Inkwell and every other take, until Dad says otherwise. The invariant also tells the next session not to re-argue either side: the exception is his ruling, and so is the rule it relaxes.

This is not the trick the second lesson warned against, and the difference is where the cut falls. That trick cut wherever a sentence happened to end, to start a second sooner. This one cuts only at a blank line, between paragraphs, where a person reading aloud takes a breath anyway, and never inside a paragraph or a sentence. The flow inside each paragraph is still one take.

Where a Paragraph Ends

A paragraph ends just past a blank line that follows a line of text or a closing fence, and only outside every code fence and card. A fence closes on a line of at least as many backticks, so a four-backtick card that holds a three-backtick code block, blank lines and all, stays one block. A paragraph with nothing to say (audio tags alone, a card alone, the emotion tag) joins the next one, because a tag alone is no take. Tool-call ends keep their place in the order. The rule lives in three places: paragraphEnds and takeSpokenUnits on the web, paragraph_ends and spoken_units on the engine, SpokenUnits on the phone. The web and the engine read one fixture of cases, and the phone's tests restate those cases inline; the web test also streams every case in one character at a time and checks it lands in the same units.

No Gap Between Paragraphs

Paragraphs would buy nothing if each one waited for its own synthesis to start. So the next unit is warmed while the current one plays: the phone asks POST /api/tts/prepare with warm: true for each unit queued behind the one she is reading, which starts that take's synthesis at once, and the web prefetches it. A private conversation's take is never warmed. The replay in Track 9 follows the same rule: it cuts a stored answer into the same paragraphs, so the voice cache still hands back the very takes that aired, and each paragraph replays from the moment its text reached the screen.

Code

A minimal sketch: paragraph ends outside fences, and a paragraph with nothing to say joining the next·python
import re

TAG = re.compile(r"\[(?:emotion:)?[a-z][a-z ]*\]")             # audio tags and the emotion tag
CARD = re.compile(r"^(`{3,})card[ \t]*\n(?:.*?\n)??\1`*[ \t]*$\n?", re.MULTILINE | re.DOTALL)   # closes on as many or more


def paragraph_ends(content: str) -> list[int]:
    """Just past each blank line that follows text or a closing fence, outside every fence."""
    ends, fence, line = [], 0, 0
    while (newline := content.find("\n", line)) != -1:
        text = content[line:newline]
        ticks = len(m.group(1)) if (m := re.match(r"(`{3,})", text)) else 0
        opens = not fence and ticks > 0
        if opens:
            fence = ticks                       # a four-backtick card keeps its inner ```
        elif fence and ticks >= fence and re.fullmatch(r"`+[ \t]*", text):
            fence = 0
        if not fence and not opens and newline > line and content[newline + 1:newline + 2] == "\n":
            ends.append(newline + 2)
        line = newline + 1
    return ends


def says_something(unit: str) -> bool:
    return bool(TAG.sub("", CARD.sub("", unit)).strip())


def spoken_units(content: str, final: bool) -> list[str]:
    """Every unit ready now; a paragraph with nothing to say joins the next."""
    units, start = [], 0
    for end in paragraph_ends(content):
        if says_something(content[start:end]):
            units.append(content[start:end].strip())
            start = end
    if final and start < len(content) and says_something(content[start:]):
        units.append(content[start:].strip())
    return units


reply = (
    "응, 오늘 저녁은 해변 쪽으로 걷자. 해 지는 게 8시 10분쯤이래.\n\n"
    "[softly]\n\n"                                   # nothing to say: joins the next
    "가는 길에 들를 만한 데를 카드에 적어 뒀어.\n\n"
    "````card\n| 어디 | 몇 분 |\n|---|---|\n| 말라게타 해변 | 15 |\n\n```text\n(코드 블록 속 빈 줄)\n```\n````\n\n"
    "춥진 않을 거야. 얇은 겉옷 하나면 돼.\n\n"
    "[emotion:warm]\n"                             # the face's tag: never a take of its own
)
half = reply[: reply.index("가는 길에") + 5]       # the stream so far
print("while streaming:", spoken_units(half, final=False))
for n, unit in enumerate(spoken_units(reply, final=True), 1):
    print(n, repr(CARD.sub("", unit).strip()))      # what the voice gets: the card stays on screen

# Output:
# while streaming: ['응, 오늘 저녁은 해변 쪽으로 걷자. 해 지는 게 8시 10분쯤이래.']
# 1 '응, 오늘 저녁은 해변 쪽으로 걷자. 해 지는 게 8시 10분쯤이래.'
# 2 '[softly]\n\n가는 길에 들를 만한 데를 카드에 적어 뒀어.'
# 3 '춥진 않을 거야. 얇은 겉옷 하나면 돼.'
#   (the card's blank lines never cut it, the card alone joined the paragraph after it,
#    and the closing [emotion:warm] says nothing, so the voice gets no take of it)

External links

Exercise

Run the sketch. Then break it on purpose three ways and predict each result first: put a blank line inside the card's table, add a paragraph that is only [laughs], and give spoken_units a list of tool-call ends (like unit_ends in this track's second lesson) with one falling mid-paragraph. Finally, add the streaming test: feed the reply in one character at a time, collect the units each step makes ready, and check they equal the units of the finished reply.
Hint
A blank line inside the card changes nothing, because the fence is still open. A paragraph of [laughs] alone has nothing to say, so it joins the next one and rides that take as a performed tag. A tool call ends a unit where it falls, even mid-paragraph, the same as before the exception. The streaming test is the one that catches an off-by-one: a paragraph's end is known only once the second newline arrives, never at the first.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.