Skip to content
C.W.K.
Lesson 07 of 07 · published

Whole Again: Zero Breaks Before a Fast Start

~15 min · speakable-units, streaming-audio, measurement, invariants

Level 0Muted
0 XP0/64 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete
"1, 2초 늦게 시작하더라도 안 끊기는 게 더 중요해." ("Even if she starts a second or two later, not breaking up matters more.") — Dad, Málaga, the night of 2026-10-19 (2026-10-20 in Korea), on build 157

A Reply That Spoke One Sentence

The lesson before this one closed the gaps between paragraphs by warming the next take while the current one played, and on 2026-10-19 the stronger version of that idea measured true: with every unit's take opened the moment the unit was queued, twenty boundaries between units on build 155 fell silent for only 0.09 to 0.17 seconds each. That same evening in Málaga, on build 156 over hotel Wi-Fi with AirPods, a reply was whole on the server (done at 40.4 seconds, no error) and whole on the screen, and Dad heard "원칙은 하나야." ("There is one principle.") and nothing more. The phone said "Reconnecting — The engine did not answer in time."

The access log on the Mac that was then the Pippa machine had the cause. When the reply's tool finished, the phone fired four take prepares, four take streams and the 1.96 MB microphone-rate copy of his recording in the same second. The unit just starting starved for 8.7 seconds, and every other call timed out on the same link. Both bursts were ours. Opening every take at once was the gapless fix itself, and the copy that went up "behind" the turn at its done landed, under paragraph reading, in the middle of her reply. Fourteen minutes earlier Bellows' own rows had shown the same shape: five takes of one reply created within 0.75 seconds, and the units playing ran dry three times, 8.51 seconds in all, while their siblings were being made. The gap measurement had measured the silence between units. Nothing had measured the starvation inside one.

The Buffer That Held Her Voice Back

Earlier that day, in the crowded Picasso Museum on LTE, Dad had already named the other half: "이제 이어지긴 하는데 특히 첫 응답이 뚝뚝 끊기는 현상이 잦아." ("It flows on now, but the first answer in particular keeps breaking up.") The gaps between units were gone; this was choppiness inside a unit, mostly the first, the only one played while its bytes arrive. The cause sat in the kit's StreamingSpeechPlayer, the player of every reading outside a hands-free talk. Parsed packets waited in a 32 KB buffer, about two seconds of 128 kbit/s audio, before the audio queue saw them. A stream arriving only a little faster than it plays left the speaker dry while the audio sat in that buffer, and a dry queue then played each scrap the moment it came: a word, silence, half a word. The queue started after 0.35 seconds of audio, whatever the link.

cwkSiblingKit a89401e (2026-10-19) took the buffer out: packets reach the queue as each piece of the body is parsed. The first sound waits for a cushion of audio on hand, and a queue about to run dry while the body still arrives holds once and refills, then plays on: one pause, never a stutter. A unit is never split or restarted. On the kit's bench, a congested cell (1.6x real time, held 0.3 to 0.9 seconds every second) went from two stalls and 1.48 silent seconds to none.

Two Rulings, Both by Ear

At 20:14 that evening Dad gave the first: "처음에 그냥 통째로 읽기를 했을 때가 훨씬 기능이 나았던 것 같은데" ("I think it worked much better at first, when it just read the whole thing"). Build 157 (cbe99bd0) made whole-answer reading the default on the phone again. The line before a tool is still read as it comes; the rest of a finished answer is one take, read through. Prefetch is bounded too: only the playing unit and the next have their take open (PippaSpeechPlayback.prefetchDepth = 1), which keeps the reading gapless and leaves the playing unit at most one competitor. Later that night, in the MacBook's WebUI, Dad heard the web do what the phone no longer did: "끊기는 데다 아예 건너 뛰어" ("it breaks up, and it skips whole parts"). Build 157's switch had reached the phone only.

The second ruling came on build 157 itself. A whole answer over hotel Wi-Fi still ran dry three times in its opening seconds, because the cushion on a good link was still 0.35 seconds. Dad's answer is this page's epigraph: between a fast start and a reading that never breaks, he chose the reading.

So the paragraph lesson's exception came back out. Invariant 19, one synthesis per speakable unit, never stopped being the rule; the one exception Dad granted on 2026-10-18 lasted a day, and the same ear that granted it withdrew it. The paragraph cutter is not deleted. It waits behind a switch, and it comes back only if a day of measured turns shows it beating whole-answer reading on first audio, zero breaks and zero false "Reconnecting".

One Switch, Every Cutter Tested Against It

A switch on one surface turned out to be worse than none. A take's identity in Bellows is its unit's text, so every cutter has to agree to the character: the screen's takeSpokenUnits, the server's spoken_units (which GET /api/onair/record and the On Air replay use to find takes that already exist) and the phone's SpokenUnits. While the phone read whole and the server still cut stored replies into paragraphs, the record check could name takes that were never made. So the switch lives in one place, the shared fixture's reads_by_paragraph (frontend/src/__tests__/fixtures/spoken-units.json, set to false), and each surface's constant is tested against it: the screen's READS_BY_PARAGRAPH, the server's voice_turn.READS_BY_PARAGRAPH and the phone's AppModel.readsByParagraph. Two lists of cases pin both settings: whole_answer_cases pins the default cut on screen and server, and cases keeps the paragraph cut honest behind the switch. Flip the fixture alone, and every cutter that did not follow fails its test.

A Cushion Sized for No Break First

The second ruling became numbers in CwkKitCore.SpeechCushion (cwkSiblingKit 21507b4, build 158). Before the first sound a reading holds 1.5 seconds of audio on a good link and 2.0 seconds on a costly one (cellular, expensive or constrained). A reading that ran dry adds 0.5 seconds for the readings after it, remembered for ten minutes, because a congested cell stays congested for minutes, not for one reply, and nothing is ever held longer than 3.0 seconds. A refill after a stall is never less than the costly start, since a stall proves the link is costly. And a link the phone cannot name yet counts as costly: build 157's first turn after launch read link=unknown on Wi-Fi and started at the smallest cushion, so the link monitor now starts at launch. At the three to four times real-time synthesis measured then, the deeper cushion cost roughly 0.4 to 0.8 seconds before her first word. There is one cushion per process, so what one reading learned holds for the next, and the talk's own engine (VoiceLoopSpeechPlayer) keeps the same rule.

The server measures the other side. Each [tts-take] line on the Pippa machine carries need=: the cushion that take alone would have needed from its first byte never to run dry. A break whose take needed more than the phone's cushion was the synthesis; one whose take needed less was the network. The sketch below runs the cushion against a link that sags for two seconds.

Her Voice Has Deadlines

A cushion answers a slow link. It does nothing for a dead one, and under whole-answer reading one unit can be the whole answer. The voice-mode review of 2026-10-22 gave her voice deadlines. A take with no first byte in 15 seconds answers 504 and its synthesis is abandoned, so the retry starts a fresh one instead of joining the stall. Bellows' stream is read with a 30-second limit between chunks instead of one 320-second number. A take Bellows cuts off midway ends as a broken transfer (TakeCutShort, logged as [tts-take] … failed short_at=), never as a whole short answer, because a player cannot tell a truncated take from her finishing. And a hold that gains no audio for 20 seconds (SpeechCushion.holdDeadline) ends the reading as .stalled: in a hands-free talk the phone first asks for the same take again and reads on from where she was heard, and only a second failure is said ("Her voice stopped early") and reported. Track 6's "The Exception Paths" follows the rest of that review.

What It Bought, and What It Costs

The first full measurement came after the Pippa machine moved: 103 phone voice turns on 2026-10-21 and 22, 90 of them over cellular, every one with underruns=0, and a single gap above 0.3 seconds. The breaks were gone. The price is written down beside them. From the end of Dad's speech to her first sound, dictated turns took a median 31.0 seconds (p90 47.5). About 13 seconds of that was the model's first text with thinking on, a median 5.7 seconds was waiting for done, which is whole-answer reading itself, and 3.4 seconds ran from the unit to her first sound. The review called the two big pieces posture choices, not defects, and the 5.7 seconds is Dad's trade for zero breaks, made with the numbers in front of him.

So this quest's v5 is superseded, not erased, the way fast-by-default was before it: a quality default the listener re-chose by ear, an exception that came back out, and one switch with every cutter tested against it. Track 10's "A False Alarm, and One Measurement per Cause" follows the other thread of that Málaga night, the "Reconnecting" line that was never true.

Code

A runnable sketch after CwkKitCore.SpeechCushion (cwkSiblingKit): the cushion, a refill after a stall, the 20 s hold deadline, against a link that sags for two seconds·python
# A sketch of CwkKitCore.SpeechCushion (cwkSiblingKit): how much of a streamed
# take is held before her first sound, and again after the speaker ran dry.
GOOD, COSTLY, GROWTH, CEILING, MEMORY = 1.5, 2.0, 0.5, 3.0, 600   # seconds
RECOVERED, HOLD_DEADLINE, TICK = 1.5, 20.0, 0.05


class Cushion:
    """One per process, so what one reading learned holds for the next."""

    def __init__(self, good: float = GOOD) -> None:
        self.good, self.learned, self.learned_at = good, 0.0, None

    def taught(self, now: float) -> float:
        fresh = self.learned_at is not None and now - self.learned_at < MEMORY
        return self.learned if fresh else 0.0

    def start(self, link: str, now: float = 0.0) -> float:
        base = COSTLY if link in ("cellular", "unknown") else self.good   # also expensive, constrained
        return min(CEILING, max(base, self.taught(now)))

    def refill(self, link: str, now: float = 0.0) -> float:
        return min(CEILING, max(COSTLY, self.start(link, now)))           # a stall proves it

    def record(self, stalls: int, held: float, now: float = 0.0) -> None:
        if stalls:
            base = max(held, self.taught(now), COSTLY)
            self.learned, self.learned_at = min(CEILING, base + GROWTH), now


def sagging_link(length: float, legs: list[tuple[float, float]]) -> list[float]:
    """Audio seconds on hand at each tick, for a link given as (until wall second,
    x real time) legs: here 1.3x, then two seconds of a cell sagging to 0.4x."""
    got, t, out = 0.0, 0.0, []
    while got < length:
        rate = next((r for until, r in legs if t < until), legs[-1][1])
        got = min(length, got + rate * TICK)
        t += TICK
        out.append(got)
    return out


def play(on_hand: list[float], cushion: Cushion, kind: str) -> tuple[float, int, float]:
    """First sound once the start is on hand; a dry speaker holds once and refills."""
    start, length = cushion.start(kind), on_hand[-1]
    on_hand = on_hand + [length] * int(HOLD_DEADLINE / TICK)
    played, first, stalls, silent, held_at, quiet_at = 0.0, None, 0, 0.0, None, 0.0
    for tick, got in enumerate(on_hand):
        now = (tick + 1) * TICK                       # seconds since the first byte
        if first is None:
            if got >= min(start, length):
                first = now
            continue
        if held_at is not None:                       # holding after a stall
            gained, waited = got - on_hand[held_at], now - (held_at + 1) * TICK
            if got > on_hand[tick - 1]:
                quiet_at = now                        # audio arrived: the deadline restarts
            if got >= length or gained >= cushion.refill(kind) or (
                    gained >= cushion.good and gained >= RECOVERED * waited):
                silent, held_at = silent + waited, None
            elif now - quiet_at >= HOLD_DEADLINE:     # no audio gained for 20 s, not 20 s held
                raise TimeoutError("stalled: reopen the take at the heard position")
            continue
        played = min(got, played + TICK)
        if played >= length:
            break
        if played >= got:
            stalls, held_at, quiet_at = stalls + 1, tick, now
    cushion.record(stalls, start)
    return round(first, 2), stalls, round(silent, 2)


take = sagging_link(8.0, [(2.0, 1.3), (4.0, 0.4), (99, 2.0)])   # an 8 s take
for good in (0.35, 1.5):
    first, stalls, silent = play(take, Cushion(good), "wifi")
    print(f"good-link cushion {good:.2f} s: first sound {first} s, stalls {stalls}, silent {silent} s")

cushion = Cushion()
print("a link not known yet starts at", cushion.start("unknown"), "s")
cushion.record(stalls=1, held=1.5, now=0)
print("after a stall:", cushion.start("wifi", now=60), "s; eleven minutes later:", cushion.start("wifi", now=660), "s")

# Output:
# good-link cushion 0.35 s: first sound 0.3 s, stalls 1, silent 1.45 s
# good-link cushion 1.50 s: first sound 1.2 s, stalls 0, silent 0.0 s
# a link not known yet starts at 2.0 s
# after a stall: 2.5 s; eleven minutes later: 1.5 s
verbatim, backend/services/voice_turn.py (cwkPippa), as of 2026-10-27: the server's copy of the one switch·python
# Whether a finished answer is read by paragraph (Dad, 2026-10-19, todo #556:
# whole is the default on every surface). The one value lives in the shared
# fixture (frontend fixtures/spoken-units.json ``reads_by_paragraph``); this
# constant, the screen's ``READS_BY_PARAGRAPH`` and the phone's
# ``AppModel.readsByParagraph`` are each tested against it.
READS_BY_PARAGRAPH = False
verbatim, backend/tests/test_voice_turn.py (cwkPippa), as of 2026-10-27: the server tested against the fixture, and the default cut pinned·python
    def test_follows_the_one_reading_switch(self):
        # Dad, 2026-10-19 (todo #556): whole answers on every surface, one switch.
        assert voice_turn.READS_BY_PARAGRAPH is _SPOKEN_UNIT_FIXTURE["reads_by_paragraph"]

    @pytest.mark.parametrize("case", _WHOLE_ANSWER_CASES, ids=lambda case: case["name"])
    def test_reads_whole_as_the_screen_reads(self, case):
        assert voice_turn.spoken_units(case["content"], case["unit_ends"]) == case["units"]

External links

Exercise

Run the sketch and read its four lines. Then predict each result before you run it. First, give play the link "unknown" with both good-link cushions. Second, deepen the sag to 0.2x for four seconds and ask the cushion what the next reading would start at. Third, make the middle leg 0.0x for 25 seconds, catch the TimeoutError, and write the reopen: a second play of the same take that skips the seconds already heard, reported only if it fails too. Finally, write the switch test for a project of your own: one fixture value, one assertion per implementation.
Hint
On an unknown link both runs start at 2.0 s, because the costly floor beats any good-link value. A sag to 0.2x stalls even at 1.5 s, and the next reading then starts at 2.5 s, which is what the ten-minute memory is for. The deadline counts seconds with no new audio, not seconds held, so a trickle that keeps arriving never trips it. For the reopen, the heard position is the seconds played, not the seconds received: audio that arrived but never sounded was never heard. The switch test is the cheapest line in this lesson, and it is the one that would have caught the server still cutting paragraphs while the phone read whole.

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.