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

Stages, and the Distance Pippa Says

~15 min · swift, navigation, voice, rounding, testing

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

Three Times per Turn

A good co-driver does not narrate. She tells you about a turn early enough to change lanes, again when it is close, and once more right before it. Pathfinder encodes exactly that as AnnouncementStage: early, prepare and imminent, ordered so the engine can compare them.

The trigger distances depend on speed. In town the plan is 1,000 m, 300 m and 90 m. Once the smoothed speed reaches 19.4 m/s (70 km/h) the engine switches to the highway plan, 2,000 m, 1,000 m and 300 m, because at 100 km/h the town plan's 300 m warning would leave about eleven seconds. The speed it uses is smoothed (each new reading counts 40 %, the previous estimate 60 %), so one noisy sample pulls the estimate only part of the way toward itself. That damps jitter; it does not forbid a switch. Near the threshold a single reading can still tip the plan: an estimate of 19 m/s and one reading of 21 m/s make 19.8 m/s, and there is no hysteresis band holding it back. What makes such a flip cheap is the rule in the next section: a stage once spoken is never spoken again, so a flip can bring the next line forward but can never make Pippa repeat one.

The early and prepare lines carry a distance; imminent carries none. It is said as "잠시 후" (soon) and goes out at urgent priority, which lets it interrupt a less urgent line already playing. A real line from the voice tests: 300미터 앞에서 + 백마로로 + 우회전해, three cached chunks of Pippa's voice.

Once Each, and Only the Latest

The engine keeps a small map, announced, from step index to the highest stage already spoken. On each matched fix it finds the next spoken step, works out which band the car is in, and speaks only if that band is higher than what was already said. Three behaviours fall out of that one rule, and each has a test:

  • Each stage once. A town drive past the turn produces early, prepare, imminent for it, and nothing twice.
  • A gap speaks only the latest. If a tunnel swallows the fixes and the next one arrives 80 m before the turn, the car has crossed two triggers at once. Only imminent is spoken. Saying "1 km ahead" at 80 m would be worse than silence.
  • Starting inside a band does not repeat. The start line ("안내 시작할게 …") already names the next maneuver with its real distance, so when the drive begins 600 m before a turn, that band is marked as spoken; prepare and imminent still follow.

Right after a maneuver, the next one may already be inside its early band. It is spoken at once with its real distance, the doc comment's "then, in 600 m ...".

Chaining Close Turns

Two maneuvers 100 m apart cannot each get their own warning: by the time the first is done, the second is already happening. So at prepare or later, if the following maneuver lies within chainDistance (180 m) of this one, it rides along with "이어서" (then): 잠시 후 우회전해 이어서 좌회전해. The early stage never chains, because 1 km out the second turn is not yet useful information.

What Is Said Is Not What Is Measured

At the moment the prepare trigger fires the car might be 287 m from the turn, because fixes arrive once a second and the car moves 12 m between them. Nobody wants to hear "287 m". SpokenDistance.from(metres:) rounds what is measured into what is said:

MeasuredSaid inExamples
under 75 m10 m steps, at least 1043 → 40미터
75 to under 950 m100 m steps, 100 to 900149 → 100미터, 287 → 300미터
950 to under 2,900 mhalf-kilometres, at least 11,260 → 1.5킬로미터
2,900 m and upwhole kilometres7,420 → 7킬로미터
NaN, infinite or a sentinel"soon"never converted

That last row is a crash avoided, not a style choice: converting a provider's "no route" sentinel or a NaN to Int would trap. And the rounding has a second payoff. Because the plan can only ever speak a small, closed set of distances, the composer lists "the fixed lines and every distance the plan can speak" and Pippa's voice for all of them can be fetched before the first drive.

Arrival, and the Eight Seconds After

The destination is a step too, so it gets its own early, prepare and imminent lines. The engine declares arrived when what remains of the line is within 35 m plus the fix's accuracy (counted up to 25 m), or when the car is past 90 % of the line and within 35 m of the destination pin itself, because routes end at the curb and the pin may sit inside a car park. It says arrived once; after that update returns nothing.

The app then ends the drive eight seconds later, once, and only for the trip that arrived. It used to schedule an end on every fix after arrival, and a late one could end the next drive or clear a new preview, a bug a code review caught. The engine was right all along; the shell had to learn to act on an event exactly once.

Measure precisely, speak coarsely. Keep every metre in the state, and round only at the moment the words are chosen. The rounding is a voice decision; the state is a guidance decision, and they should never share a number.

Code

GuidanceEngine.swift: the town plan and the highway switch (verbatim excerpts)·swift
  /// Highway announcement plan from this speed (m/s; 19.4 = 70 km/h).
  public var highwaySpeed: Double = 19.4
  /// Two maneuvers closer than this are spoken together.
  public var chainDistance: Double = 180
  /// The town plan's trigger distances (the highway plan is fixed).
  public var townPlan: [AnnouncementStage: Double] = [.early: 1000, .prepare: 300, .imminent: 90]

  // ...

  /// The trigger distances for each stage at a given speed.
  public func stageDistances(speed: Double?) -> [AnnouncementStage: Double] {
    if let speed, speed >= highwaySpeed {
      return [.early: 2000, .prepare: 1000, .imminent: 300]
    }
    return townPlan
  }
GuidanceEngine.swift: nextAnnouncement(), once per stage, with chaining (verbatim)·swift
  /// The most urgent stage the car has reached for the next maneuver, once.
  /// Crossing two triggers between fixes (a GPS gap) speaks only the later
  /// one; right after a maneuver, the next one inside its early band is
  /// spoken at once with its real distance ("then, in 600 m ...").
  private mutating func nextAnnouncement() -> Announcement? {
    guard let next = nextSpokenStep(), let stage = currentBand(for: next.step) else { return nil }
    let distance = next.step.maneuverAlong - progress
    if let done = announced[next.index], done >= stage { return nil }
    announced[next.index] = stage
    let following = route.maneuvers.first { $0.index > next.index }
    let chained: String? = {
      guard stage >= .prepare, let following,
        following.step.maneuverAlong - next.step.maneuverAlong <= config.chainDistance
      else { return nil }
      return following.step.instruction
    }()
    return Announcement(
      stepIndex: next.index, stage: stage,
      distance: stage == .imminent ? .soon : SpokenDistance.from(metres: distance),
      instruction: next.step.instruction, kind: next.step.kind, thenInstruction: chained)
  }
SpokenDistance copied verbatim, with a small driver that prints what Pippa would say (compiled and run with swiftc)·swift
import Foundation

// SpokenDistance, verbatim from GuidanceEngine.swift (origin/main):
/// A distance in the words a driver hears: rounded, never "287 m".
public enum SpokenDistance: Hashable, Codable, Sendable {
  case soon
  case meters(Int)
  /// Tenths of a kilometre, so 1.5 km is `kilometers(tenths: 15)`.
  case kilometers(tenths: Int)

  public static func from(metres: Double) -> SpokenDistance {
    // A provider's "no route" sentinel or a NaN is not a distance: said as
    // "soon", never converted (an Int conversion of it would trap).
    guard metres.isFinite, metres < 100_000_000 else { return .soon }
    // Ten-metre steps below the driving plan's shortest stage (walking).
    if metres < 75 {
      return .meters(max(10, Int((metres / 10).rounded()) * 10))
    }
    if metres < 950 {
      let rounded = max(100, Int((metres / 100).rounded()) * 100)
      return .meters(min(rounded, 900))
    }
    if metres < 2900 {
      // Half-kilometre steps up to 3 km ("1.5 km", "2 km", "2.5 km").
      return .kilometers(tenths: max(10, Int((metres / 500).rounded()) * 5))
    }
    return .kilometers(tenths: Int((metres / 1000).rounded()) * 10)
  }
}

// A driver (written for this lesson): the words PhraseBook.words(for:) builds.
func words(_ d: SpokenDistance) -> String {
  switch d {
  case .soon: return "잠시 후"
  case .meters(let m): return "\(m)미터"
  case .kilometers(let t):
    return t % 10 == 0 ? "\(t / 10)킬로미터" : String(format: "%.1f킬로미터", Double(t) / 10)
  }
}

for measured in [7.0, 43, 74, 76, 149, 287, 951, 1260, 2740, 2950, 7420, .infinity] {
  print("measured \(measured) m -> said \(words(SpokenDistance.from(metres: measured)))")
}
// measured 7.0 m -> said 10미터
// measured 43.0 m -> said 40미터
// measured 74.0 m -> said 70미터
// measured 76.0 m -> said 100미터
// measured 149.0 m -> said 100미터
// measured 287.0 m -> said 300미터
// measured 951.0 m -> said 1킬로미터
// measured 1260.0 m -> said 1.5킬로미터
// measured 2740.0 m -> said 2.5킬로미터
// measured 2950.0 m -> said 3킬로미터
// measured 7420.0 m -> said 7킬로미터
// measured inf m -> said 잠시 후

External links

Exercise

Pick a number your own product shows or says to a person: a file size, a remaining time, a distance, a price change. Write a function that turns the precise value into what is displayed, with explicit bands like SpokenDistance has, and a test table that includes one value on each side of every band boundary plus NaN and infinity. Then count how many distinct outputs your function can ever produce.
Hint
If the count of distinct outputs is unbounded, the display is just the measurement with fewer digits. A closed, small set is what lets you precompute, cache or translate every output, the same property that lets Pathfinder fetch Pippa's distance phrases before a drive.

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.