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

The Voice Lane: Measured From Málaga

~17 min · latency, reranking, timeline, measurement

Level 0Muted
0 XP0/61 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete
"The latency is certain. A typed turn, I barely feel it. It's only slow when I chat in voice mode, phone or web." — Dad, Málaga, 2026-10-18

From the Road, Again

The first lesson of this track measured a turn at a desk, on office's own network. Three weeks later Dad was talking to Pippa from hotel Wi-Fi and LTE in Spain, and the numbers he lived with were not the ones in that lesson. His first verdict on Lightning (Track 3's newest lesson) was blunt: it was no use at all, and the wait before thinking even began was long on every brain. So the house measured again, stage by stage, on the night of 2026-10-18, and this time it measured both sides of the request, not only office's.

Where a Spoken Turn's Time Went

stagewhat it iscost that night
end of speechScribe's silence window, Dad's setting3 s by default
noisy-place re-readthe batch model reads the utterance againabout 1 s, only in noisy mode
speaker checkthe recording to cwkBellows' ear (Track 8)1.4–3.9 s
retrievalembed 0.1 s, then the 4B reranker on one thread2–9 s
system prompt buildcached vault base0.3–0.8 s, hidden behind retrieval
Claude CLI startspawn, connect, the CLI's own initinit at 13.7–41 s
provider to first tokenLightning in Master Chief (~280K tokens), cold / warm cache12–13 s / 2.3–5 s
Claude Opus in Master Chief (~400K tokens, nearly all cached)7.5–10 s after init
each tool calla Claude shell or MCP toolabout 10 s
first audioprepare plus the stream's first byte0.9–1.6 s

Two of those rows are not Pippa's code, and the design doc says so: the CLI's slow init and its ten seconds per tool both traced to office's file-watch daemon, a host problem filed with the fleet's own issue tracker. The rows that were Pippa's had a shape anyone could see once they sat in one table. The speaker check and retrieval ran one after the other, so a Master Chief voice turn's preparation read 5.4 to 8.7 seconds.

Side by Side, and Heard Early

Every chat route now starts retrieval before the speaker check (an EarlyRag that the route collects later), so the preparation costs the longer of the two instead of their sum. The order that matters is untouched: the speaker check still rules the turn before Dad's row is stored, and if the final query or scope differs from the one retrieval started on, the early answer is thrown away and the route runs its own. The check itself moved earlier too. The phone uploads its 16 kHz take 20 to 45 seconds before the turn, so the upload route now hears it there, and the turn takes that answer when it arrives. On the web the upload and the turn land in the same second, so /api/stt keeps the recording before Scribe runs and starts the check beside it. After both, the route's own lines read 1.1 to 3.3 seconds of preparation with the speaker check at zero, voice and typed turns alike.

The Voice Lane for Memory

The second lesson of this track closed on a ruling: memory quality comes first, and half a second to two seconds is meaningless unless it is voice mode. The 4B reranker stayed, and the faster options were measured and declined. On 2026-10-18 Dad used the clause he had written into that ruling. The reranker became an Admin pick with a pick of its own for spoken turns: rag_rerank_model for written turns (empty means the server's configured model) and rag_rerank_voice_model for spoken ones, Lightning included (empty means the written pick). The choices are read live from the model server, which refuses a model it does not list, and the phone's Settings carries the same two picks. Dad set the voice lane to the 0.6B reranker. A short spoken retrieval went from 2.74 seconds on the 4B to 0.63 on the 0.6B. Written turns still rank with the 4B.

The price is the one that lesson measured: on those 20 prompts the 0.6B kept about three quarters of the chunks the 4B retrieved. That price is now paid on spoken turns only, it was chosen by the person who pays it, and every turn says what it paid: the route's [turn-prep] line names the reranker lane and model it used.

One Timeline per Voice Turn

Office's own lines start when a request arrives and end when the model is done. Everything Dad felt on either side of them, the recording crossing the link and her voice coming back, was unrecorded. Now each voice turn reports one timeline. The client sends it once, at her first sound: seconds before its POST (end of speech, the whole transcription), each recording's crossing (bytes and seconds, with office's own work subtracted through Server-Timing headers), and seconds after (first text, done, first unit, first audio). Office joins its own stages, logs one [voice-turn] line, writes it to the conversation's JSONL first and then to Dad's row. A private conversation is logged by label only and stored nowhere.

The first seven phone turns over LTE gave the wait its real size: a median of 44.6 seconds from the end of Dad's speech to her first sound. Of the 33.4 seconds before the turn even left the phone, 11.1 were pure transfer, 220 to 560 KB at 10 to 40 KB a second. After it, office's first text came at 8.0 seconds, but the stream took 4.5 to 17.5 seconds to cross on the three turns whose 48 kHz recording was uploading beside the reply, instead of 0.4 to 1.5. And her first sound came a median 5.2 seconds after her first text, because a reply's last unit was its whole remaining text.

Each number pointed at its own fix, and none of them touched the soul. The 48 kHz copy now goes up after her answer, behind the turn. A photo handed to the talk starts uploading while Dad is still talking, under its own hash so the same photo always names the same stored upload: single-photo turns had waited 17 to 23 seconds for it after his words. The web reads a reply's last unit at its done instead of when the stream closes, which had cost 2 to 5 seconds. And a chat answer is read paragraph by paragraph, the new lesson at the end of Track 4.

Code

A minimal sketch: a voice turn's totals from both sides of its POST, and preparation one after the other versus side by side·python
# One voice turn's timeline: the client's half before and after its POST, the
# uploads in between, and the sums. The shape follows cwkPippa's voice_timeline.build.


def totals(before: dict, uploads: list[dict], after: dict) -> dict:
    """Seconds before the POST count back from it; seconds after count from it."""
    out = {"speech_end_to_post": before["speech_end"]}
    ahead = [u for u in uploads if u["before_post"]]
    # What the link cost, with office's own work on each upload taken out.
    out["transfer_before_post"] = round(sum(u["seconds"] - u["server_seconds"] for u in ahead), 1)
    out["post_to_first_audio"] = after["first_audio"]
    out["speech_end_to_first_audio"] = round(before["speech_end"] + after["first_audio"], 1)
    out["first_text_to_first_audio"] = round(after["first_audio"] - after["first_text"], 1)
    return out


def prep(speaker_check: float, rag: float, side_by_side: bool) -> float:
    """The route's work before the model is asked: one after the other, or the longer of two."""
    return max(speaker_check, rag) if side_by_side else speaker_check + rag


# A phone turn over LTE built from build 152's medians (2026-10-18). Medians of
# different turns do not add up: the measured median of the whole wait was 44.6 s.
before = {"speech_end": 33.4, "transcribed": 30.1}           # seconds before the POST
uploads = [
    {"kind": "listen", "bytes": 380_000, "seconds": 12.6, "server_seconds": 1.5, "before_post": True},
    {"kind": "archive", "bytes": 3_100_000, "seconds": 40.0, "server_seconds": 0.4, "before_post": False},
]
after = {"first_text": 14.1, "first_audio": 19.3}              # seconds after the POST

for name, value in totals(before, uploads, after).items():
    print(f"{name:26s} {value:5.1f} s")
print()
for label, side in (("one after the other", False), ("side by side", True)):
    print(f"turn prep, {label:20s} {prep(speaker_check=2.6, rag=2.74, side_by_side=side):4.2f} s")
# Heard early at the upload, the speaker check costs the turn nothing; the voice lane reranks with the 0.6B.
print(f"turn prep, side by side, 0.6B  {prep(speaker_check=0.0, rag=0.63, side_by_side=True):4.2f} s")

# Output:
# speech_end_to_post          33.4 s
# transfer_before_post        11.1 s
# post_to_first_audio         19.3 s
# speech_end_to_first_audio   52.7 s
# first_text_to_first_audio    5.2 s
#
# turn prep, one after the other  5.34 s
# turn prep, side by side         2.74 s
# turn prep, side by side, 0.6B  0.63 s

External links

Exercise

Run the sketch and read both halves. Then move the archive upload in front of the POST (before_post: True) and see which total grows, and by how much. Next, give prep() a third mode: retrieval starts early but its answer is thrown away because the query changed, so the route runs a second pass. Finally, instrument one request path of your own on both sides: stamp the client's moments, return a Server-Timing header, and report the link's share separately from the server's.
Hint
With the archive in front, transfer_before_post jumps by about 39.6 seconds, the archive's crossing minus office's own work. speech_end_to_post does not move, because the sketch takes it as a measured input; in a real turn those seconds would sit inside it, which is exactly the build 149 shape that put 21 to 78 seconds before the POST. A thrown-away early pass costs the speaker check plus a full retrieval, the same as the old one-after-the-other sum: starting early never loses time against that baseline, it only stops winning, and the route discards only when the query or scope really differs. For Server-Timing, send dur in milliseconds and subtract it from the client's measured round trip.

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.