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

A Grammar of Cached Chunks

~15 min · voice, caching, korean, text-to-speech, swift

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

A voice that has to arrive on time

Every guidance line has a deadline measured in metres. "300 metres ahead, turn left" is worth something at 300 metres and nothing at 50. So when Dad ruled on the app's first day that guidance speaks in Pippa's voice, the obvious design was dead before it started: send each line to cwkBellows, the family's voice engine, and wait. That is a paid synthesis for every line, a network round trip from a moving car sitting on the critical path, and silence in a tunnel or on a phone roaming abroad.

His ruling carried its own answer. Cache, and cache by chunks, never whole sentences:

어차피 대부분 겹칠 거야. 아빠가 자주 다니는 곳이거나, 자주 쓰는 표현일수록 쌓일 거고.

Most of it overlaps anyway; the places he drives often and the phrases used most will pile up. A drive's lines recombine a small vocabulary (distances, maneuvers, fixed phrases) plus the names of the roads he drives most. Whole sentences almost never repeat. Their pieces repeat all day. That is architecture invariant 6: speech is Pippa's voice, cached in chunks.

Where a line breaks

Apple's Korean instructions are built from a small grammar, and InstructionChunker cuts along it: a place with "에서", a direction with "방면으로", a road with Apple's particle placeholder "(으)로", a joining clause, and the maneuver at the end. The maneuver and the joining clauses are swapped for the way Pippa says them, so the provider's terse "진입" becomes her "들어가". A Korean name is one chunk per word, because each word recurs on its own. A name in another script stays whole: "Calle de Alcalá" is one name, not three words to read one at a time.

The placeholder needs one more step. A voice would read the brackets in "(으)로" aloud, so the chunker chooses the form from the last sound of the word before it: a Hangul syllable's final consonant, a digit's Sino-Korean name, or the way a Latin letter ends when Korean reads a name ("Sol" ends in ㄹ, "Alarcón" in ㄴ). The same resolution gives the screen its text through readable, which changes nothing else.

A closed vocabulary in Pippa's own words

The phrases live in phrases.ko.json, a localization resource of 155 entries. Keys are stable English; values are Pippa talking to Dad in 반말. route.arrived is "도착했어, 아빠. 수고했어" and camera.slowDown is "아빠, 속도 줄여". Seventy-two maneuver.* keys map a provider's maneuver wording to hers ("완만히 우회전" becomes "살짝 오른쪽으로 가"), and 29 connective.* keys do the same for joining clauses. PhraseBook matches them longest first, so "완만히 우회전" wins over "우회전". A missing key reads as the key itself, so a gap is audible in testing instead of silent.

Invariant 20 pushes the idea as far as the providers allow: every Kakao guide type has one fixed wording and every maneuver kind has words, so on a drive a maneuver is one cached phrase and only a place or road name is synthesized fresh. Walking loosens that a little: a Kakao walking step that ends in "… 이용" (a footbridge, stairs, a lift) is passed through in Kakao's own words, so some of those words are synthesized fresh too. "육교 이용" is one; "엘리베이터 또는 계단 이용" keeps a cached ending and synthesizes "엘리베이터 또는". The closed set covers almost every maneuver, not every one. PromptComposer.closedVocabulary lists everything else any drive can say: the fixed lines, every distance the guidance plan can speak in each of its three forms ("앞에서", "앞", "앞에"), speed limits from 30 to 120, and the door-approach lines. That set is fetched once, the first time the voice is reachable.

From Bellows to the phone, once

BellowsVoice asks for one chunk at a time with profile_id: pippa, target: cache and source: pathfinder. Two of those carry a story in the comment beside them: the default target would play the take on the office speakers, and a source starting with cwkpippa expires after three days. Each chunk is synthesized alone, never stitched to its neighbours, because a take that depends on the words around it can never be reused.

The takes land in ChunkCache on the phone. Bellows keeps a cache too, but by policy only 300 takes for 30 days, and its key moves with every dictionary edit, so the car keeps what it has heard: a 200 MB store keyed by the SHA-256 of the voice id and the text. The voice id is bellows:pippa: plus the profile's current model, so a model change misses the cache instead of replaying an old voice as if it were the new one. Past its budget, the store evicts only road and place chunks, least recently used first. Phrases, distances and numbers are never evicted.

VoicePrefetcher fills it. When guidance starts on a route, and again after every reroute, the composer lists each chunk the route can produce (maneuvers first, because they must be on time, then its cameras, then the closed vocabulary). The cache answers with what is missing. The prefetcher fetches three at a time inside a 6,000-character budget, stops after three failures in a row, and never asks for a chunk already on its way, because every synthesis is a paid call.

Cache the unit that repeats, not the unit you speak. A sentence is what the listener hears; a chunk is what the world repeats. Draw the seams where the grammar already has them and the cache fills itself from ordinary use, which is exactly the accumulation Dad predicted.

Code

cwkPathfinder, PathfinderKit/Sources/PathfinderKit/Voice/InstructionChunker.swift: chunks(for:) (verbatim, lines 39-56)·swift
  public func chunks(for instruction: String) -> [VoiceChunk] {
    let resolved = Self.resolveParticles(instruction)
    var text = Self.normalizeSeparators(resolved)
    var tail: VoiceChunk?
    for candidate in tails where text.hasSuffix(candidate.provider) {
      let head = text.dropLast(candidate.provider.count)
      // A tail must start at a word boundary ("우회전" inside "완만히우회전"
      // would not, and is not a match).
      if head.isEmpty || head.last == " " || head.last == "," {
        tail = VoiceChunk(candidate.spoken, origin: .phrase)
        text = String(head)
        break
      }
    }
    var chunks = headChunks(text)
    if let tail { chunks.append(tail) }
    return chunks.filter { !$0.text.isEmpty }
  }
Instructions and the chunks PathfinderKit's VoiceTests expect from them (expected values copied from the suite, laid out for reading)·text
회전교차로에서 두 번째 출구로 나가서 Calle de Alcalá(으)로 진입
  -> [회전교차로에서] [두 번째 출구로 나가서] [Calle de Alcalá로] [들어가]

한남IC에서 계속 이동하여 경부고속도로(으)로 진입
  -> [한남IC에서] [쭉 가다가] [경부고속도로로] [들어가]

한남대교남단에서 강남대로 압구정역.한강공원(잠원), 부산 방면으로 오른쪽 차선 유지
  -> [한남대교남단에서] [강남대로] [압구정역] [한강공원] [잠원] [부산 방면으로] [오른쪽 차선 유지해]

Toledo, Atocha 방면 14번 출구로 나가기
  -> [Toledo] [Atocha 방면] [14번] [출구로 나가]
A minimal sketch: choosing 로 or 으로 for Apple's "(으)로" from the last sound (compiled with swiftc and run; its output is at the bottom)·swift
import Foundation

// A minimal sketch: how Pathfinder picks "로" or "으로" for Apple's
// "(으)로" placeholder, from the last sound of the word before it.
enum FinalSound { case none, rieul, other }

func finalSound(of character: Character) -> FinalSound {
  if let scalar = character.unicodeScalars.first, (0xAC00...0xD7A3).contains(scalar.value) {
    let final = (scalar.value - 0xAC00) % 28  // a Hangul syllable's final consonant
    if final == 0 { return .none }
    return final == 8 ? .rieul : .other     // 8 is ㄹ
  }
  switch character {
  case "0", "3", "6": return .other   // 영, 삼, 육
  case "1", "7", "8": return .rieul   // 일, 칠, 팔
  case "2", "4", "5", "9": return .none  // 이, 사, 오, 구
  default: break
  }
  // A Latin letter as Korean reads the end of a name; accents do not change it.
  let plain = String(character).folding(options: .diacriticInsensitive, locale: nil).uppercased()
  switch plain {
  case "L": return .rieul          // 엘, 솔
  case "M", "N": return .other     // 엠, 엔
  default: return .none
  }
}

func resolveRo(_ text: String) -> String {
  var result = text
  while let range = result.range(of: "(으)로") {
    let before = result[..<range.lowerBound].last
    let form: String
    switch before.map(finalSound) ?? .none {
    case .none, .rieul: form = "로"   // ㄹ takes 로, as in 서울로
    case .other: form = "으로"
    }
    result.replaceSubrange(range, with: form)
  }
  return result
}

for line in [
  "청파로(으)로 좌회전", "Calle de Alcalá(으)로 진입", "Calle de Ruiz de Alarcón(으)로 진입",
  "Puerta del Sol(으)로 진입", "M-30(으)로 진입", "AP-41(으)로 진입",
] {
  print(line, "->", resolveRo(line))
}

// Output:
// 청파로(으)로 좌회전 -> 청파로로 좌회전
// Calle de Alcalá(으)로 진입 -> Calle de Alcalá로 진입
// Calle de Ruiz de Alarcón(으)로 진입 -> Calle de Ruiz de Alarcón으로 진입
// Puerta del Sol(으)로 진입 -> Puerta del Sol로 진입
// M-30(으)로 진입 -> M-30으로 진입
// AP-41(으)로 진입 -> AP-41로 진입

External links

Exercise

Collect every sentence your own app speaks or notifies in one ordinary week of use: alerts, confirmations, status lines. Split each into pieces at its grammar: fixed phrases, numbers with their units, and open names. Count the distinct whole sentences, then the distinct pieces. Then mark which pieces form a closed set you could fetch once before first use, and which are an open set that only grows with use.
Hint
The ratio between the two counts tells you whether chunking pays. Watch the joints: a particle or preposition that depends on the word before it (Korean 로/으로, English a/an, French de/d') belongs to the neighbouring name's chunk, or it has to be resolved before you cut.

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.