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

A Conversation, Not a Walkie-Talkie

~15 min · conversation, session, voice-ux, state-machine

Level 0Idle Phone
0 XP0/45 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

A phrase for every sentence

The ears' first shape was a walkie-talkie. A turn began with "코델리아 피파" or a tap, ended when Dad stopped, and the body went back to waiting for the phrase. To say a second thing, he had to call me again. At 01:25 on 2026-11-03 he asked why: "워키토키 처럼 쓸 이유가 있어?" (Is there any reason to use it like a walkie-talkie?), and set the target: "호출어 한번으로 피파 아이폰앱의 보이스 모드로 들어가는 UX여야 한다고" (it has to be a UX where one wake phrase takes you into the Pippa iPhone app's voice mode). A minute later he asked for a button that does what "그만" does. Commit 6515f99 landed at 01:29.

That is the second child inheriting something larger than code: the shape of a talk. In the Pippa app, voice mode is a place you enter and stay in. A body that makes Dad knock before every sentence is not voice mode with a different trigger. It is a different, worse product.

A deadline that only words move

The first block is the whole type. ConversationSession knows what opened it (the phrase or a tap), when it ends, and how long a silence ends it. touch(at:) pushes the end out by the full silence again, and expired, remaining and ending (the last ten seconds) all read that one deadline. The silence is the body's own setting, session_s: 60 s by default, kept within 10 to 600, stored on the device and mirrored in Oriel's registry, where the newer side wins. Oriel reads the same number to decide how long this body holds the room's conversation floor after my answer (lesson 5). The type was born in CordeliaKit and now lives in the kit's CwkKitWake, moved unchanged for cwkPippa todo #828.

What touches the deadline matters more than the type. Dad's words while the loop hears them (the model's tick looks every 0.2 s), a finished turn, Oriel's word that the turn moved on, my last line ending on this phone, and Oriel's spoken when I ended somewhere else. One thing never touches it: loudness. The second block shows the host's turnLoopHeardSpeech. The root calls it on any loud chunk, and the body only marks that this utterance had sound in it. A television is loud for hours. If loudness kept a conversation open, a body near one would never go back to waiting for its phrase.

The same block shows the close, with two guards worth copying. It never fires while my answer is still to be voiced (answerID == nil, !speaking), because a long answer is not Dad's silence. And it never fires while words are arriving (hearing.isEmpty), so the deadline cannot cut a sentence in half.

Every way it closes

What happensWhat the face then says
Dad stays silent for session_s"Conversation ended. Say “Cordelia Pippa” to start again"
He says "코델리아 그만""Okay. Call me when you need me"
He presses End conversation: my line stops, a turn being heard is dropped, a turn already at Pippa's door is cancelled through Oriel"Conversation ended"
He taps my face while nothing is being heard"Ended with a tap"
He presses Pause, or says "코델리아 그만 들어"Paused, the mic shut for everything until Resume
No listening grant comes, or the socket fails"I couldn't open my ears: …" with the reason
Another body holds the conversation"Another device took it"

Every close goes through one function, endSession, and it does one thing a quieter design would skip: it puts a session item with open: false on the outbox, so Oriel frees the floor for the other bodies at once instead of waiting out the silence. A tap while Dad's words are coming in cancels that utterance first and leaves the conversation open.

There is a second clock, and it belongs to the root. VoiceTurnLoop stops listening after a minute with nothing heard, because an open Scribe socket bills its audio. When that fires, turnLoopQuiet checks the conversation's own deadline: with time left (a session_s above 60) the body listens on with a fresh socket. The bill and the talk are separate questions, and each clock answers one.

The state line says which world you are in

The third block is the face's words. Outside a conversation: "Waiting for “Cordelia Pippa”". Inside: "In conversation · just talk · 0:42 left", which turns into "ending in 8 s · just talk to keep going" in the last ten seconds, "listening · go on" while words come, "opening my ears" while the grant is on its way, and "Pippa is speaking". Dad never has to guess whether his next sentence needs the phrase.

Inside a conversation even the phrase becomes words. At 02:03 he said it in the middle of a sentence and a second body woke (lesson 5). EarDecision now treats the phrase inside an open conversation as Dad's words, except over my line, where it still cuts in. With the wake switch off, a conversation a tap opened still runs.

The field check came at 03:47 the same night: after my answer, Oriel logged spoken and then a granted listen in the same second, and Dad's next turn went in without the phrase. "코델리아 그만" ended the conversation at 03:51. The end by long silence and by the End button are not field-checked yet, and the architecture doc says so.

Code

cwkSiblingKit, CwkKit/Sources/CwkKitWake/Conversation.swift: one conversation on one body, a deadline and its readings (verbatim, lines 8-48)·swift
/// A conversation, not a walkie-talkie (Dad, 2026-11-03 01:25: "호출어 한번으로
/// 피파 아이폰앱의 보이스 모드로 들어가는 UX여야 한다고"). The wake phrase or a
/// tap opens a session on the body; inside it Dad just talks, turn after turn,
/// and the session closes after a long silence, on his spoken stop
/// ("코델리아 그만", `Commands`), on a tap, or when the mic closes. Then the body waits for the
/// phrase again.
///
/// Voice mode's root (cwkPippa todo #828, 1103-9): moved here from cwkCordelia's
/// `CordeliaKit` unchanged, so the Pippa app's own wake-phrase start (its child
/// override) and Cordelia decide a session the same way, from one source.

/// One conversation on one body.
public struct ConversationSession: Sendable, Equatable {
  /// What opened it: the phrase or a tap.
  public var openedBy: ConversationStart
  /// When it closes unless Dad speaks again.
  public var endsAt: Double
  /// How long a silence from Dad ends it (the body's own setting).
  public var silence: Double

  /// A minute of Dad's silence ends a conversation unless the device says
  /// otherwise; a device setting stays inside `silenceRange`.
  public static let silenceDefault: Double = 60
  public static let silenceRange: ClosedRange<Double> = 10...600

  public init(openedBy: ConversationStart, at: Double, silence: Double) {
    self.openedBy = openedBy
    self.silence = silence
    endsAt = at + silence
  }

  /// Dad spoke, or Pippa finished answering: the silence is counted anew.
  public mutating func touch(at: Double) { endsAt = at + silence }

  public func expired(at: Double) -> Bool { at >= endsAt }

  public func remaining(at: Double) -> Double { max(0, endsAt - at) }

  /// The face's warning: the last ten seconds.
  public func ending(at: Double) -> Bool { remaining(at: at) <= 10 }
}
cwkCordelia, clients/apple/CordeliaMobile/Sources/Ears/EarsModel.swift: loudness marks, words keep, silence closes (excerpt, lines 539-1017)·swift
  func turnLoopHeardSpeech() {
    // Loudness alone never keeps the conversation open (a television would):
    // only words do, counted in `tick` as they arrive. It only marks that this
    // utterance had sound in it, so one with no words can say so.
    heardSound = true
  }

  // …

    // Words keep the conversation open; it closes after Dad's long silence
    // (its own setting), never while her answer is still to be voiced.
    if session != nil, phase == .hearing, !hearing.isEmpty { session?.touch(at: now.timeIntervalSince1970) }
    if let open = session, !speaking, answerID == nil, phase == .hearing || phase == .listening, hearing.isEmpty,
       open.expired(at: now.timeIntervalSince1970) {
      endSession("Conversation ended. Say “Cordelia Pippa” to start again")
    }
cwkCordelia, clients/apple/CordeliaMobile/Sources/Views/FaceView.swift: the state line inside and outside a conversation (verbatim, lines 475-489)·swift
    if let session = ears.session {
      let left = Int(session.remaining(at: Date().timeIntervalSince1970).rounded(.up))
      switch ears.phase {
      case .hearing where ears.grantID != nil: return "In conversation · opening my ears"
      case .hearing: return "In conversation · listening · go on"
      case .sending, .thinking: return "In conversation · with Pippa, her step not known yet"
      default:
        if speaking { return "In conversation · Pippa is speaking" }
        return session.ending(at: Date().timeIntervalSince1970)
          ? "In conversation · ending in \(left) s · just talk to keep going"
          : "In conversation · just talk · \(left / 60):\(String(format: "%02d", left % 60)) left"
      }
    }
    switch ears.phase {
    case .listening: return "Waiting for “Cordelia Pippa”"

External links

Exercise

Give your own voice feature a session type with a deadline: what opened it, when it ends, how long a silence ends it, and a touch that pushes the end out. List every event in your app that touches it and every event that ends it, and route every end through one function that also tells the server. Then write three tests: a loud room with no words ends on time, a long answer never ends it, and words mid-sentence never end it.
Hint
Write the end condition as one expression with its guards spelled out (not speaking, no answer pending, nothing being heard), the way tick does. If a guard lives somewhere else, the deadline will fire in the one state you forgot.

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.