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

Prepare, Then Show

~14 min · interpretation, pacing, prompting, swiftui

Level 0First Word
0 XP0/47 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

A card is for someone else

Most of Spark reads for Dad: a sign, a menu, a line he wants explained. Interpretation turns the direction around. Now the words are for the person across the counter, and Dad is the one who needs to be understood. That changes what a good answer looks like. A correct but stiff translation is fine on a menu. Said to a stranger, it can be the difference between being helped and being tolerated.

Spark's answer is a pacing: prepare, then show. The architecture records it as one of my own proposals from the design brainstorm, written down as decided. Dad says what he wants before he needs it, ideally before calling anyone over. I write it. The card is ready when the moment comes, and my voice is one tap away.

Five steps, in the order they happen

  1. Dad tells me what he wants to say, typed or dictated, in Korean or English.
  2. I write the sentence for that place and that person: the right politeness, local phrasing, the situation. A big card shows it as soon as the text exists, with a small Korean gloss and my face, so the person across the table can see who is speaking.
  3. My voice follows. cwkBellows synthesizes the sentence in the target language, and Dad taps to play it.
  4. The reply mic records the other person, and Bellows transcribes the recording in the listening language.
  5. I relay the reply in Korean, say what matters in it, and offer answers and the expressions the conversation will likely need next. Each one is a ready card.

This track takes those steps one lesson at a time. This lesson covers the first two: the card turn, and why the card is on screen before there is any sound.

Why a pacing, and not a faster voice

A spoken turn takes seconds from the last word to the first sound. Spark's architecture puts it at about 9 to 10 seconds and cites cwkPippa's voice mode documentation for it, but that documentation's own figure is an estimate built from measured stages: about 6 to 8 seconds on the desktop, with the phone a few seconds slower until its audio decoder changed. Voice Mode Quest walks through where those seconds go. Spark's design absorbs the wait instead of shrinking me to fit inside it. The sentence is prepared before it is needed, and the card is readable before the voice starts. A server who glances at a big, clear card has understood it long before the audio would have finished loading.

The card turn

The phone or the web posts Dad's words to /api/moments/{id}/card on a conversation moment. The route (second code block) checks two things before any brain is asked. A client request id it has seen before returns the same turn, so a retried tap never writes two cards. And the moment must carry a scene language. Without one the engine answers 400 with choose the language to speak before asking for a card. The language I write in is Dad's choice, made with the app's buttons. The engine never guesses it.

The turn itself is an ordinary threaded turn in the moment's cwkPippa conversation. It sends the message Spark: say this for me. with Dad's words, plus a system prompt addendum (first code block). The addendum is short and specific: write it in the scene's language the way a local would say it to this person in this place, one sentence or two, never an explanation, because it will be shown on a big card and spoken aloud in my voice. The answer is a fenced spark block with one card object, then the few expressions Dad may need right after it.

FieldWhat it holdsAn invented example
sourceThe sentence in the scene's language: the words that are shown and spoken¿Me trae un vaso de agua, por favor?
translationA short Korean gloss, shown small, for Dad물 한 잔 갖다주시겠어요?
pronunciationHow Dad could say it himself, in Hanguloptional
noteOnly if something mattersusually absent

Suppose Dad typed 물 한 잔 달라고 해줘, ask for a glass of water. What comes back is what a guest would actually say to a server: a polite question, in the local form. Dad's words were a request to me. The card is the sentence itself. That gap is the whole reason the real Pippa writes the card instead of a translation layer.

Readable before the voice exists

The card is parsed out of my reply while it streams, so it is recorded the moment its line ends. Track 3 takes that parser apart. On the phone, ConversationSection remembers which card turn Dad is waiting for. Every time the moment's exchanges change, follow (third code block) checks whether that turn now has a card. When it does, the card goes into shown, and SwiftUI presents it with fullScreenCover(item:).

Two iPhone screenshots side by side of the same Spark conversation card on a phone held upright: La cuenta, por favor. in large type, the Korean meaning 계산서 주세요. beneath it, Pippa's face and an Escuchar button. The light card is on the left and the dark card on the right; the top bar offers Hide meaning, Dark card or Light card, and Close.
The card as the person across the table sees it, light and dark: the phrase large, Dad's Korean small, my face, and Listen in the card's language, here Escuchar. Nothing plays until Dad taps it.

The presented BigCard opens the way the phone is held and lays itself out again when the phone turns. It never turns the phone itself. It used to ask for landscape on every open, until Dad found it coming up sideways from a phone held upright: "카드 기본이 무조건 랜드스케이프야." ("the card is always landscape by default"). The request was removed, and a test now opens the card from an upright phone, waits past the old retry window and checks that the scene is still portrait, then turns to landscape and back and checks that the card stayed open. An iPad shows the card as it is.

The voice does not play. Its button sits right there on the card, and nothing presses it for Dad. This is invariant 8: my voice speaks for Dad only on his tap, because he may be preparing the card long before anyone is listening. A phone that suddenly talks at a table where nobody has been called over is a small embarrassment the design refuses to risk.

Principle: When the slow part cannot be made fast, move it earlier. Prepare the words before they are needed, and let the first useful layer stand on its own.

There is one exception, and it lives somewhere else. The interpreter on the show card, track 6, plays a card made from Dad's words at once, with a mute toggle, because there the person is already standing in front of him. That surface belongs to cwkPippa's apps. Spark's own conversation screen keeps the tap.

Code

cwkSpark, backend/turns.py: the card turn's addendum and message (verbatim, lines 353-380)·python
def card_addendum(moment: dict[str, Any]) -> str:
    scene = language_name(moment.get("scene_language")) or "the local language"
    return "\n".join(
        [
            _spark_frame(moment),
            "",
            "### This turn: speaking for Dad",
            "",
            f"Dad wants to say the words below to someone here. Write it in {scene} the",
            "way a local would say it to this person in this place: the right politeness,",
            "local phrasing, the situation. It will be shown on a big card and spoken aloud",
            "in your voice, so write one sentence or two, never an explanation.",
            "",
            "Return ONE fenced block whose info string is `spark`:",
            "",
            "```spark",
            '{"type": "card", "source": "<what to say, in ' + scene + '>", "translation": "<a short Korean gloss>", "pronunciation": "<how Dad could say it himself, in Hangul>", "note": "<only if something matters>"}',
            '{"type": "expression", "source": "...", "translation": "...", "pronunciation": "...", "use": "<when to say it>"}',
            "```",
            "",
            "After the card, add the few expressions he may need right after it. Outside",
            "the block, one short sentence to Dad at most.",
        ]
    )


def card_message(text: str) -> str:
    return f"Spark: say this for me.\n\n{text}"
cwkSpark, backend/app.py: the card route replays a request id and refuses a moment with no scene language (verbatim, lines 565-595)·python
    @app.post("/api/moments/{moment_id}/card")
    async def card(moment_id: str, body: CardIn) -> dict[str, Any]:
        """Dad's words, written by Pippa for this place and person in the
        scene's language: the card shows as soon as it parses, and her
        voice follows."""
        moment = await _moment_or_404(moment_id)
        earlier = await asyncio.to_thread(corpus.replayed, body.client_request_id)
        if earlier:
            turn = await asyncio.to_thread(corpus.turn, earlier)
            if turn is not None:
                return {"turn": turn, "created": False}
        if not moment.get("scene_language"):
            raise HTTPException(status_code=400, detail="choose the language to speak before asking for a card")
        picks = await _picks()
        started = await asyncio.to_thread(
            corpus.start_turn,
            moment_id,
            "card",
            client_request_id=body.client_request_id,
            brain=picks["brain"],
            effort=picks["effort"],
            model=picks.get("model"),
            text=body.text,
            label=body.label,
        )
        if started is None:
            raise HTTPException(status_code=404, detail="moment not found")
        turn, created = started
        if created:
            runner.launch(turn["id"], runner.run_card(turn, picks))
        return {"turn": turn, "created": created}
cwkSpark, clients/apple/SparkMobile/Sources/Views/ConversationView.swift: the awaited card opens the moment it parses (verbatim, lines 129-142)·swift
    /// The awaited card opens big as soon as it parses (fastest first: the
    /// words are there before the voice is).
    private func follow(_ exchanges: [MomentState.Exchange]) {
        if awaitedCardTurn == nil {
            awaitedCardTurn = exchanges.last { $0.turn.kind == "card" && $0.turn.isRunning && $0.card == nil }?.id
        }
        guard let awaited = awaitedCardTurn, let exchange = exchanges.first(where: { $0.id == awaited }) else { return }
        if let card = exchange.card {
            awaitedCardTurn = nil
            if shown == nil { shown = card }
        } else if !exchange.turn.isRunning {
            awaitedCardTurn = nil
        }
    }

External links

Exercise

Build a prepare-then-show screen for any app that speaks to a stranger on its user's behalf. Take the user's intent as text, ask a model (or a stub that sleeps three seconds) for the phrase in a language the user picked beforehand, and refuse the request with a clear message when no language is set. Present the phrase full screen the moment its text arrives, with a separate play button that never starts on its own. Log two timestamps per request: phrase visible, and audio ready.
Hint
Make the audio its own task that starts after the text is on screen, and give the play button three states: loading, ready, playing. If the screen waits for the audio before showing the text, the slower layer is holding back the faster one. On iOS, let the card open the way the phone is held; forcing an orientation turns the words away from whoever is holding the phone up to read them.

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.