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

The Road Comes First

~14 min · voice, audio, arbitration, pure-functions, swift

Level 0Lost
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

Two voices, one audio route

On its second day Pathfinder took on a second voice. Dad asked for Pippa inside the navigator:

피파까지 심어진 차/도보 내비 앱이어야 해. 내비 도중 피파 보이스모드 가능해야 하고. 운전/도보 중 피파랑 자연스러운 대화가 가능한.

A car and walking navigator with Pippa planted in it: her voice mode during guidance, a natural conversation while driving or walking. The day before, navigation had been ruled out of the Pippa app so her voice mode would stay stable. So the talk came to the road, rather than the road to the Pippa app, and the architecture doc gives the reason in one line: one app on the audio route is the only way the guidance and her voice can share it without talking over each other, or the microphone hearing the guidance as Dad.

Two apps would each hold an audio session and know nothing about the other. The navigator could not ask "is she in the middle of a sentence?", and the talk could not know that the voice in the cabin is a turn instruction rather than Dad. Inside one process, both questions have an answer.

Why the talk has to be told

Her talk plays on the microphone's own audio engine: CwkKitVoice switches on Apple's voice processing for that engine, and the engine's own output is the reference its echo canceller subtracts, so her voice comes out of what the mic hears. Guidance plays on a separate player, VoicePlayer, outside that engine. Pathfinder's architecture doc puts it flatly: the mic's echo cancellation cannot hear that player. Read that as the premise this configuration is designed on, not as a law of echo cancellation; how much of a second player Apple's processing might catch on a given device is something the app neither measures nor leans on. If the talk trusted the canceller and it missed, its ears would transcribe "300미터 앞에서 좌회전해" as something Dad said, and its barge-in detector would cut her off for it.

So the talk is the guidance voice's gate. Before every line, VoicePlayer asks GuidanceVoiceGate.guidanceLineArrives whether the line may play now, and it reports guidanceLine(playing:) when a line starts and ends. PippaTalk implements both. The rules it follows are not in the app at all: they are TalkInterplay, a pure function in PathfinderKit, under test on a Mac with no audio hardware anywhere near it.

The table

Talk phaseLine priorityAction
off, paused, stoppedanyspeak: the line plays as it always did
connecting, listening, thinkinganyspeakOverListening: play now, and shut the talk's ears until it ends
speakinginfodrop: a status line is not worth interrupting her for
speakingurgentcutHerThenSpeak: she stops where she is
speakingguidancewaitForHer if her current stretch ends within 3 s, otherwise cutHerThenSpeak

The three seconds are a bound, and the code's comment argues for it: at 100 km/h three seconds is about 80 metres, "inside the rounding of 1 km or 300 m". Check that arithmetic before you repeat it. A held line keeps the words it was composed with. A "300미터" line that waits the full three seconds at 100 km/h (about 83 m) plays with roughly 217 m left, which SpokenDistance would now say as 200 m; a "1킬로미터" line composed at 1,000 m plays at about 917 m, a "900미터" reading. So the wait can cost one rounding step, always in the same direction: the line names a little more road than is left. What keeps that acceptable is the line that never waits. An urgent line ("잠시 후", "속도 줄여") cuts straight in, because its whole point is now, and on the highway plan the 300 m stage is exactly that urgent "잠시 후", so the line that says the turn is here never waits. And "within 3 s" is only known once the player has decoded her whole stretch. While it is still arriving, herRemaining is nil, and a guidance line cuts.

What the app does with each answer

  • Ears shut. While a guidance line sounds, and for half a second after it (earsAfterGuidance: the cabin still rings), mic chunks are not passed to the listener at all. Barge-in is never judged against the road either.
  • Waiting. A held line replaces any older held line, because the newest one is the one that matters. When her stretch ends, the held line plays before her next stretch. A deadline of three and a half seconds backs the promise: if her stretch ran longer than it said, she is cut off and the line plays.
  • Cutting. She stops where she is, cwkPippa is told how far Dad heard (voice mode's heard_until, so her next answer picks up from there), and the talk listens again at once with its ears still shut for the line. A cut-in check in progress is dropped: the road is not Dad talking.
  • Her next stretch. If a guidance line is playing when her next stretch is ready, the stretch waits for the line to finish.
  • The session. While the talk holds the audio session in .playAndRecord, the guidance player leaves the category alone and plays inside it.

The same gate covers a provider's own voice. When Google speaks the turns, its voice is muted while the talk holds the microphone, so a spoken road name never lands in her conversation as Dad's words.

Who yields is a policy, and policies belong in pure code. The audio plumbing (sessions, players, sockets) is hard to test and easy to get subtly wrong. The decision about who speaks is five rows. Pathfinder keeps the rows in a function with no side effects and lets the app only carry out the answer, so the rule Dad's safety depends on is the part with a test.

Code

cwkPathfinder, PathfinderKit/Sources/PathfinderKit/Talk/TalkPhase.swift: TalkInterplay.maxWait and action (verbatim, lines 64-91)·swift
  /// The longest a guidance line waits for her stretch to end. At 100 km/h
  /// that is 80 m, inside the rounding of "1 km" or "300 m"; an urgent line
  /// ("잠시 후", "속도 줄여") never waits.
  public static let maxWait: TimeInterval = 3

  /// `herRemaining` is how much of her current stretch is left to play, when
  /// the player knows (its whole take decoded); nil while it is still
  /// arriving.
  public static func action(
    priority: Utterance.Priority, phase: TalkPhase, herRemaining: TimeInterval? = nil
  ) -> Action {
    switch phase {
    case .off, .paused, .stopped:
      return .speak
    case .connecting, .listening, .thinking:
      return .speakOverListening
    case .speaking:
      switch priority {
      case .info:
        return .drop
      case .urgent:
        return .cutHerThenSpeak
      case .guidance:
        if let herRemaining, herRemaining <= maxWait { return .waitForHer }
        return .cutHerThenSpeak
      }
    }
  }
cwkPathfinder, clients/apple/PathfinderMobile/Sources/Talk/PippaTalk.swift: guidanceLineArrives(_:) (verbatim, lines 1087-1122)·swift
  func guidanceLineArrives(_ line: Utterance) -> Bool {
    var remaining: TimeInterval?
    if let progress = readingProgress(), progress.ended {
      remaining = max(0, progress.decoded - progress.played)
    }
    switch TalkInterplay.action(priority: line.priority, phase: phase, herRemaining: remaining) {
    case .speak, .speakOverListening:
      return true
    case .drop:
      return false
    case .waitForHer:
      // The newest line is the one that matters; an urgent one never waits.
      heldLine = line
      heldDeadline?.cancel()
      heldDeadline = Task { [weak self] in
        try? await Task.sleep(for: .seconds(TalkInterplay.maxWait + 0.5))
        guard !Task.isCancelled, let self, self.heldLine != nil else { return }
        // Her stretch ran longer than it said: the road does not wait more.
        if self.phase == .speaking {
          self.cutInHoldStore?.cancel()
          self.cutIn()
          self.listen()
        }
        self.releaseHeldLine()
      }
      return false
    case .cutHerThenSpeak:
      // She stops where she is; the talk listens again at once, and the
      // closed ears keep the line out of what it hears. A cut-in being asked
      // about is dropped: the road is not Dad talking.
      cutInHoldStore?.cancel()
      cutIn()
      listen()
      return true
    }
  }

External links

Exercise

Pick any app you work on that has two things competing for one output: an alert sound over media, a game's dialogue over its effects, a notification over a call, two writers to one log. Write the arbitration as a pure function: the state of each source and the priority of the newcomer in, one action out (play, wait, cut, drop). Fill in the whole table, including the rows you never thought about, and write one test per row.
Hint
The rows you skipped are where the bugs live. Ask what the newcomer is worth after a delay: if it loses its meaning in two seconds, it cannot wait; if it is a status line, it can be dropped. A bounded wait needs a deadline in the side that carries it out, because a promise of 'soon' from the other side can be wrong.

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.