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

Never Two Voices in a Line

~13 min · voice, audio, fallback, avfoundation, swift

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

The moment a line is due

Prefetching is a hope. The moment of truth is VoicePlayer.start, when a line is due and the player asks the cache for it. ChunkCache.line answers with every chunk of the line or with nothing: it walks the chunks in order, and the first one that is not on disk ends the walk with nil. Use is counted only for a line that will actually play.

A full answer goes to playPippa. It decodes each take, trims the silence each one carries at its start and end, and schedules them back to back on one AVAudioPlayerNode, so the joins are sample-accurate and five takes do not stretch one line by a second. A nil sends the whole line to AVSpeechSynthesizer with the ko-KR voice, and the missing chunks are reported so the prefetcher can fetch them for next time. Even a full set can fall through: if one take cannot be read, or two takes disagree on audio format, playPippa returns false and the line goes to the system voice rather than half of it playing.

Why not fill the gap with the system voice?

The tempting optimisation is obvious. Play the four chunks you have in Pippa's voice, and let the system voice say the one road name you do not have. Invariant 6 forbids it in one sentence: a line that is missing any chunk at speak time is spoken whole in the system voice, and two voices never mix inside one line.

The reason is how listening works at 100 km/h. A line is one utterance from one speaker. When the timbre changes halfway, the ear hears a second speaker cutting in, and the attention that should go to "left, in 300 metres" goes to who just spoke. A plain voice all the way through is easier to follow than a lovely voice with a hole in it. Being one voice is part of the message.

The rule also keeps the failure readable. Every line writes one drive-log event: spoke when it was Pippa's voice, voice_fallback when it was not, with the text. Fallbacks that cluster on road names are a prefetch that did not finish; fallbacks on fixed phrases mean the closed vocabulary never arrived. Either way the record says which, and the missing chunks are already on their way.

Priorities and the audio session

Each line carries one of three priorities. An urgent line ("잠시 후" just before a turn, "속도 줄여" at a camera) interrupts anything less urgent. A guidance line waits its turn. An info line ("back on route") is dropped when something is already waiting. The session is .playback in the .voicePrompt mode with .duckOthers and .interruptSpokenAudioAndMixWithOthers: Dad's music lowers under a line and comes back after it, while spoken audio such as a podcast pauses instead of talking over her.

The player asserts that session before every line, because a phone call or Pippa's talk may have taken it since the last one; a flag that only remembered "activated once" left the engine refusing to start after a call. A call that interrupts a line loses that line, and a media-server reset rebuilds the engine and its node. Dad picks the mode in Settings: Pippa's voice, the system voice, or silent. A simulated drive is always silent, so a test route never speaks and never spends a synthesis.

The same rule one level up: whole trips

Abroad, Google's Navigation SDK guides its own routes and Pathfinder follows its turn feed. By default Pippa still speaks each turn in her own words, built from the maneuver's shape and distance, without the road names Google's terms keep to its own voice. A setting, off by default, hands the turns to Google's voice instead, road names and all, and then Pippa says nothing over it. If Google's turn feed never arrives, no line of hers can be made, so Google's own voice takes the trip and the screen and the drive log say why.

Google's voice plays outside Pathfinder's player, so the app tells it the three things that silence the player: Dad's mute, a talk with Pippa holding the microphone, and the last stretch to a large building's door, where the provider's turns belong to the wrong level. The rule scales from a line to a trip: a trip is never guided by two voices, and never by none without saying so.

When you cannot do the whole thing the good way, do the whole thing the plain way. Partial quality is a seam, and a listener hears the seam before the content. The fallback is not a downgrade of the line; it is the line, delivered whole, with a note in the record so the good way is ready next time.

Code

A minimal sketch: the all-or-nothing decision, and the second try after the missing chunk is fetched (compiled with swiftc and run; its output is at the bottom)·swift
// A minimal sketch of the all-or-nothing rule Pathfinder's VoicePlayer
// follows: a line plays in Pippa's voice only if EVERY chunk is cached;
// otherwise the whole line goes to the system voice, and the missing
// chunks are reported so they can be fetched for next time.
struct Chunk: Hashable { let text: String }

enum Decision {
  case pippa(takes: [String])
  case systemVoice(line: String, missing: [Chunk])
}

func decide(_ line: [Chunk], cache: [Chunk: String]) -> Decision {
  var takes: [String] = []
  for chunk in line {
    guard let take = cache[chunk] else {
      // One gap is enough: never half Pippa, half the system voice.
      var seen = Set<Chunk>()
      let missing = line.filter { cache[$0] == nil && seen.insert($0).inserted }
      return .systemVoice(line: line.map(\.text).joined(separator: " "), missing: missing)
    }
    takes.append(take)
  }
  return .pippa(takes: takes)
}

func describe(_ decision: Decision) -> String {
  switch decision {
  case .pippa(let takes): return "PIPPA   " + takes.joined(separator: " + ")
  case .systemVoice(let line, let missing):
    return "SYSTEM  \"\(line)\"  (fetch: \(missing.map(\.text).joined(separator: ", ")))"
  }
}

// The closed vocabulary is cached before the first drive; road names are not.
var cache: [Chunk: String] = [:]
for phrase in ["300미터 앞", "회전교차로에서", "두 번째 출구로 나가서", "들어가", "잠시 후", "좌회전해"] {
  cache[Chunk(text: phrase)] = "\(phrase).mp3"
}

let line = ["300미터 앞", "회전교차로에서", "두 번째 출구로 나가서", "Calle de Alcalá로", "들어가"].map(Chunk.init)
print(describe(decide(line, cache: cache)))

// The prefetcher fetches the reported chunk; the next time, the same line is hers.
cache[Chunk(text: "Calle de Alcalá로")] = "Calle de Alcalá로.mp3"
print(describe(decide(line, cache: cache)))

// Output:
// SYSTEM  "300미터 앞 회전교차로에서 두 번째 출구로 나가서 Calle de Alcalá로 들어가"  (fetch: Calle de Alcalá로)
// PIPPA   300미터 앞.mp3 + 회전교차로에서.mp3 + 두 번째 출구로 나가서.mp3 + Calle de Alcalá로.mp3 + 들어가.mp3
cwkPathfinder, PathfinderKit/Sources/PathfinderKit/Voice/ChunkCache.swift: line(_:voiceID:) (verbatim, lines 79-90)·swift
  /// Every chunk of a line, or nil when any one is missing — a line is spoken
  /// in one voice, never half Pippa and half the system voice. Use is counted
  /// only for a line that will actually play.
  public func line(_ chunks: [VoiceChunk], voiceID: String) -> [Cached]? {
    var found: [(String, Cached)] = []
    for chunk in chunks {
      guard let hit = peek(chunk, voiceID: voiceID) else { return nil }
      found.append(hit)
    }
    for (key, _) in found { markUsed(key) }
    return found.map(\.1)
  }
cwkPathfinder, clients/apple/PathfinderMobile/Sources/Voice/VoicePlayer.swift: start(_:) (verbatim, lines 113-136)·swift
  private func start(_ utterance: Utterance) {
    generation += 1
    let ticket = generation
    let wasQuiet = speaking == nil
    speaking = utterance
    if wasQuiet { gate?.guidanceLine(playing: true) }
    activateSession()
    let mode = self.mode
    let voice = voiceID()
    Task { @MainActor in
      if mode == .pippa, let line = await cache.line(utterance.chunks, voiceID: voice) {
        guard ticket == generation else { return }
        if playPippa(line, ticket: ticket) {
          onSpoken?(Outcome(utterance: utterance, pippaVoice: true, missing: []))
          return
        }
      }
      guard ticket == generation else { return }
      let missing = mode == .pippa ? await cache.missing(utterance.chunks, voiceID: voice) : []
      guard ticket == generation else { return }
      speakSystem(utterance)
      onSpoken?(Outcome(utterance: utterance, pippaVoice: false, missing: missing))
    }
  }

External links

Exercise

Find a place in your own code where a result is assembled from parts that can each fail or come from different sources: a page built from cached and live fragments, a report merged from two services, a translated screen with missing strings. Write down what the user sees today when one part is missing. Then decide, and write a test for, the all-or-nothing rule: either every part comes from the good source, or the whole result comes from the plain one, and the gap is recorded so the good source can fill it next time.
Hint
Ask what the unit of consistency is for the person reading or hearing it. A sentence, a table row or a screen is usually one unit; a list of independent items usually is not. Mixing sources inside one unit is what reads as broken.

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.