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

One Press, One Turn

~15 min · state-machine, watchos, hands-free, simplification

Level 0Muted
0 XP0/57 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete
"Hands-free on the watch just doesn't work the way it does on the phone, so I think the right move is to make it manual and simply end it one turn at a time." — Dad, on the watch's hands-free talk

A Loop on the Wrist

The phone's hands-free talk is this track's circle: listen, send, wait, hear her, listen again. The watch got the same circle. A start opened the microphone, the watch decided the end of speech by its own loudness rule, the phone transcribed and sent the turn, her reply played, and then the watch listened again by itself. On a wrist that last step turned out to be the dangerous one, because every way back to the microphone was a way in for the wrong words.

  • Her own reply. The phone read a long answer into Dad's headphones for about two minutes. Ninety seconds after the phone said it was reading, the watch decided the wait was over, listened again, and sent her second paragraph as Dad's turn.
  • Someone nearby. In the same few minutes a stranger's question went out as his turn too. Five turns were sent while Dad believed the talk was off, and two of them weren't his.
  • A "Thinking" that never ended. Separately, the watch could sit on "Thinking" after her answer was over, until Dad pressed Stop and started again.

Patch It, or Remove It

The first fix was a patch, and a careful one: one switch the microphone obeys so that Stop really means off, a wait that covers the whole reading on the phone instead of a bare ninety seconds, a talk that ends when the system ends the watch app's runtime. It shipped on 2026-10-11, and the same day Dad ruled on the shape itself, in the line at the top. The loop went, not behind a flag but out of the code.

Now a start opens the microphone for one utterance. The start can be the Hands-free button on the home screen, the Action Button, or the hands-free complication. The end of speech (three seconds of quiet) or Send now (a tap on the ring) sends the utterance to the phone, and the microphone and its audio session close at once. Her reply plays: the iPhone reads it into the AirPods when it holds them (the next lesson), or the watch plays it on its own speaker. Then the turn is over, and the watch goes back to its home screen with the microphone closed. The next turn is another press.

The Pippa watch app's home screen: three buttons, 'Talk to Pippa' with a microphone, 'Talk in a new chat', and a teal 'Hands-free' button with an ear.
The watch's home screen. Hands-free starts one turn; when her reply is over, this is where the watch comes back to, with the microphone closed.

The Rule Lives in a Pure Type

The turn's whole state machine is WristVoiceTurn, a value type with no WatchKit in it, compiled into the phone's test target so the suite holds the rule without a watch. Its promise fits in two sentences. Nothing opens the microphone except audioUp after a begin. Every way a turn ends, her reply read on the phone or played on the watch, a cut-off, a reply that failed, Stop, leaving the app, lands on .off or .stopped with a reason, never on .listening.

Two details carry the weight. A start while a turn is already up or starting is ignored, because on a cold launch the control, a launch request and a URL can all ask at once. And every end bumps a generation number, so a start still waiting on microphone permission or the audio session, if Stop came meanwhile, opens nothing when its audio finally comes up. The ring means one thing per phase: while he talks it sends now, while the watch speaks it cuts her off, after a turn that ended early it starts the next one, and while the turn waits for her it does nothing at all. There is no tap that listens again.

The watch while listening: the word 'Listening' in teal, a glowing teal ring around an ear icon, and the line 'Talk to Pippa. Tap the ring to send now.'
One turn, listening. A tap on the ring sends now instead of waiting for silence; it never starts listening again.

What went with the loop is worth listing, because each piece was a reasonable answer to a real problem: the late voice the loop owed and played into the next listening window, the latch that kept the microphone shut after Stop (the generation token does that job now), the logic that decided when the phone's reading was over and listening could resume, and the tap that listened again while she was thinking. Remove the loop and none of those questions exist.

One Runtime Session, Claimed

The "Thinking" that never ended had a cause of its own, and its fix shipped beside the one-turn change. A watch app keeps running with the wrist down through an extended runtime session, and watchOS runs one per app at a time, refusing a start while an earlier one is still ending. The turn, the wait for her voice and the watch's own reading each started their own, handing off by ending one and starting the next in the same breath. The refusal was read as "fine, carry on", and with the wrist down the suspended app heard none of the phone's words about her reply.

Now there is one session for the app, and the three only claim it. It starts on the first claim while the app is in front, stays while any claim holds, and ends five seconds after the last release, so a hand-off never ends and restarts it. A refusal for an earlier session of ours still ending is tried once more after a second. And the phone, instead of saying "reading" and "read aloud" once each, repeats its latest word about each reply on every pull until the wrist stops asking, keeping the newest twenty. The todo that first named the stuck "Thinking" was closed as abandoned, superseded by the one-turn change; this second pass shipped under its name.

What Is Proven

Dad's check on his real watch closed the one-turn todo: the watch stops when the reply finishes, and the single turn works. The design still lists what no one has proven on a wrist: recording with the wrist down inside the runtime session, the loudness thresholds on a real wrist, and a phone asleep in a pocket. The iPhone's own voice mode is unchanged; it keeps the circle, because on the phone the loop has an owner for every state.

When a loop can't be made safe, remove the loop. Each way back to the microphone needed its own guard, and each guard was a new place to be wrong. One press, one turn has no way back to guard.

Code

Only audioUp after begin opens the microphone (verbatim excerpt)·swift
// clients/apple/PippaMobile/Wrist/WristVoiceTurn.swift:60-98 (cwkPippa)
    mutating func begin() -> Int? {
        guard launch == nil, phase == .off || isStopped else { return nil }
        generation += 1
        launch = generation
        return generation
    }

    /// Whether this start is still the one under way (not stopped meanwhile).
    func stillStarting(_ token: Int) -> Bool {
        launch == token && token == generation
    }

    /// The start's audio is up: the microphone opens for this turn. False
    /// when the turn was stopped meanwhile; the start then opens nothing.
    mutating func audioUp(_ token: Int) -> Bool {
        guard stillStarting(token) else { return false }
        launch = nil
        pending = nil
        phoneReading = false
        phase = .listening
        return true
    }

    /// The start could not open the microphone (permission, audio session).
    mutating func startFailed(_ token: Int, _ reason: String) {
        guard stillStarting(token) else { return }
        launch = nil
        phase = .stopped(reason)
    }

    /// The utterance left for the phone: the microphone is closed for good
    /// in this turn, and the turn waits for her.
    @discardableResult
    mutating func sent(_ id: String) -> Bool {
        guard phase == .listening else { return false }
        pending = id
        phase = .thinking
        return true
    }
The ring's meaning per phase, and an end that is always off (verbatim excerpt)·swift
// clients/apple/PippaMobile/Wrist/WristVoiceTurn.swift:191-229 (cwkPippa)
    enum Tap: Equatable {
        /// He is talking: his turn goes now, without waiting for silence.
        case sendNow
        /// She is speaking on the watch: cut her off, and the turn is over.
        case cutOff
        /// The turn ended early: start the next one.
        case start
        case nothing
    }

    /// What a tap on the ring does now. Never "listen again": while the turn
    /// waits for her the ring does nothing, and Stop ends the turn.
    var tap: Tap {
        switch phase {
        case .listening: return .sendNow
        case .speaking: return .cutOff
        case .stopped: return .start
        case .off, .thinking: return .nothing
        }
    }

    /// Stop, Close, or leaving the app.
    mutating func stop() {
        end()
    }

    /// The turn ends early, with its reason on the screen.
    mutating func halt(_ reason: String) {
        end()
        phase = .stopped(reason)
    }

    private mutating func end() {
        generation += 1
        launch = nil
        pending = nil
        phoneReading = false
        phase = .off
    }
A minimal sketch: the old loop beside the one-press turn (swiftc turn.swift && ./turn)·swift
// A minimal sketch: the watch's old loop beside the one-press turn that replaced it.
enum Phase: Equatable { case off, listening, thinking, speaking }

enum Event: String { case press, sent, replyRead, voiceNearby, stop }

/// The loop: the end of every reply was a way back to the microphone.
func loop(_ phase: Phase, _ event: Event) -> (Phase, String) {
  switch (phase, event) {
  case (.off, .press): return (.listening, "mic open")
  case (.listening, .sent): return (.thinking, "sent; mic closed")
  case (.thinking, .replyRead): return (.listening, "listens again by itself")
  case (.listening, .voiceNearby): return (.thinking, "SENT someone else's words")
  case (_, .stop): return (.off, "off")
  default: return (phase, "nothing")
  }
}

/// The turn: only a press opens the microphone, and every end is off.
struct Turn {
  private(set) var phase: Phase = .off
  private var generation = 0
  private var launch: Int?

  /// A press asks for the mic; nil while a turn is already up or starting.
  mutating func begin() -> Int? {
    guard launch == nil, phase == .off else { return nil }
    generation += 1
    launch = generation
    return generation
  }

  /// The audio came up: open the mic only if this start still stands.
  mutating func audioUp(_ token: Int) -> Bool {
    guard launch == token, token == generation else { return false }
    launch = nil
    phase = .listening
    return true
  }

  mutating func handle(_ event: Event) -> String {
    switch (phase, event) {
    case (.listening, .sent): phase = .thinking; return "sent; mic closed"
    case (.thinking, .replyRead): end(); return "reply read; off, mic closed"
    case (.off, .voiceNearby): return "mic closed: nothing heard"
    case (_, .stop): end(); return "off"
    default: return "nothing"
    }
  }

  private mutating func end() {
    generation += 1  // voids any start still on its way
    launch = nil
    phase = .off
  }
}

func pad(_ s: String) -> String { s + String(repeating: " ", count: max(0, 12 - s.count)) }

var old = Phase.off
print("-- the loop")
for event in [Event.press, .sent, .replyRead, .voiceNearby] {
  let (next, said) = loop(old, event)
  old = next
  print("  \(pad(event.rawValue)) -> \(next)  (\(said))")
}

var turn = Turn()
print("-- the turn")
if let token = turn.begin(), turn.audioUp(token) { print("  press        -> \(turn.phase)  (mic open)") }
for event in [Event.sent, .replyRead, .voiceNearby] {
  let said = turn.handle(event)
  print("  \(pad(event.rawValue)) -> \(turn.phase)  (\(said))")
}

print("-- a press, then Stop before the audio came up")
if let token = turn.begin() {
  _ = turn.handle(.stop)
  print("  audio up after Stop opens the mic? \(turn.audioUp(token)) -> \(turn.phase)")
}

// Output:
// -- the loop
//   press        -> listening  (mic open)
//   sent         -> thinking  (sent; mic closed)
//   replyRead    -> listening  (listens again by itself)
//   voiceNearby  -> thinking  (SENT someone else's words)
// -- the turn
//   press        -> listening  (mic open)
//   sent         -> thinking  (sent; mic closed)
//   replyRead    -> off  (reply read; off, mic closed)
//   voiceNearby  -> off  (mic closed: nothing heard)
// -- a press, then Stop before the audio came up
//   audio up after Stop opens the mic? false -> off

External links

Exercise

Run the sketch (swiftc turn.swift -o turn && ./turn). Then add the phases the real type has and the sketch leaves out: speaking on the watch, and stopped with a reason. Write the ring's tap for each phase as a function, and add a test for the case that broke the loop: her reply ends, and a voice nearby is loud enough to pass the end-of-speech rule. Finally, pick one loop in your own app that reopens a resource by itself and list every way back it has.
Hint
The tap is a pure function of the phase: listening sends now, speaking cuts her off and ends the turn, stopped starts the next turn, and off or thinking does nothing. In the reply-ends case the turn is already off, so the nearby voice reaches a closed microphone; that test should pass without any rule about voices at all, which is the point of removing the loop.

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.