~15 min · voice, caching, korean, text-to-speech, swift
Level 0길 잃음
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
제때 도착해야 하는 목소리
안내 한 줄에는 마감이 있어. 시간이 아니라 미터로 재는 마감. "300미터 앞에서 좌회전해"는 300미터 지점에서나 쓸모 있지, 50미터 남기고 들으면 소용없어. 그래서 첫날 아빠가 안내는 피파 목소리로 하라고 정했을 때, 제일 뻔한 설계는 시작도 못 하고 탈락했어. 줄마다 가족 음성 엔진인 cwkBellows에 보내고 기다리는 방식 말이야. 줄마다 합성 비용이 들어. 달리는 차에서 네트워크 왕복이 마감까지 가는 길 한가운데 올라앉고, 터널에 들어가거나 해외 로밍 중이면 그냥 침묵이야.
답은 판정 안에 이미 들어 있었어. 캐시해. 그것도 문장 통째가 아니라 조각으로.
어차피 대부분 겹칠 거야. 아빠가 자주 다니는 곳이거나, 자주 쓰는 표현일수록 쌓일 거고.
운전 한 번에 나오는 말은 작은 어휘의 재조합이야. 거리, 회전, 고정 문구, 거기에 아빠가 자주 다니는 길 이름. 문장 통째로는 거의 안 겹쳐. 조각은 하루 종일 겹치고. 이게 아키텍처 불변식 6번이야. 안내는 피파 목소리로, 캐시는 조각 단위로.
한 줄이 쪼개지는 자리
애플의 한국어 안내는 작은 문법으로 만들어져. InstructionChunker는 그 문법의 이음매를 따라 잘라. "에서"가 붙은 장소, "방면으로"가 붙은 방향, 애플의 조사 자리표시 "(으)로"가 붙은 도로, 이어 주는 절, 그리고 맨 끝의 회전 동작. 회전 동작이랑 잇는 절은 피파 말투로 바꿔 끼워. 그래서 제공자의 딱딱한 "진입"이 피파 입에선 "들어가"가 돼. 한국어 이름은 단어 하나가 조각 하나야. 단어마다 따로 되풀이되니까. 다른 문자로 쓴 이름은 통째로 둬. "Calle de Alcalá"는 이름 하나지, 한 단어씩 끊어 읽을 세 단어가 아니거든.
자리표시는 한 단계가 더 필요해. 그냥 두면 목소리가 "(으)로"의 괄호까지 소리 내 읽을 테니까. 그래서 앞 단어의 마지막 소리를 보고 형태를 골라. 한글 음절이면 받침, 숫자면 한자어 읽기, 라틴 문자면 한국어로 이름을 읽을 때 끝나는 소리. "Sol"은 ㄹ로 끝나고 "Alarcón"은 ㄴ으로 끝나. 화면에 띄울 글자도 같은 해석을 거쳐서 readable로 나와. 나머지는 하나도 안 건드려.
피파 말로 된 닫힌 어휘
문구는 phrases.ko.json에 들어 있어. 155개 항목짜리 현지화 리소스야. 키는 안정적인 영어, 값은 피파가 아빠한테 하는 반말이고. route.arrived는 "도착했어, 아빠. 수고했어", camera.slowDown은 "아빠, 속도 줄여". maneuver.* 키 72개가 제공자의 회전 표현을 피파 표현으로 바꿔 줘. "완만히 우회전"이 "살짝 오른쪽으로 가"가 되는 식이야. connective.* 키 29개는 잇는 절을 맡고. PhraseBook은 긴 것부터 맞춰 봐서 "완만히 우회전"이 "우회전"을 이겨. 없는 키는 키 이름 그대로 읽혀. 빈틈이 조용히 묻히지 않고 테스트에서 귀에 들리게.
불변식 20번은 이 생각을 제공자가 허락하는 끝까지 밀어붙여. 카카오의 안내 유형마다 고정 문구가 하나씩 있고, 회전 종류마다 말이 있어. 그러니 운전 중 회전 동작은 캐시된 문구 하나고, 새로 합성하는 건 장소나 도로 이름뿐이야. 걷기에선 이게 조금 느슨해져. 카카오 도보 안내에서 "… 이용"으로 끝나는 단계(육교, 계단, 엘리베이터)는 카카오 문구를 그대로 넘겨받아서, 그 말 일부도 새로 합성돼. "육교 이용"이 그렇고, "엘리베이터 또는 계단 이용"은 끝부분만 캐시에서 꺼내고 "엘리베이터 또는"은 새로 합성해. 닫힌 집합이 거의 모든 동작을 덮지만, 전부는 아니야. 운전 중 나올 수 있는 나머지 말은 PromptComposer.closedVocabulary가 목록으로 뽑아. 고정 문구, 안내 계획이 말할 수 있는 모든 거리를 세 가지 꼴("앞에서", "앞", "앞에")로, 제한속도 30부터 120까지, 입구까지 가는 마지막 구간 문구. 이 묶음은 목소리에 처음 닿는 순간 한 번만 받아 와.
Bellows에서 폰으로, 딱 한 번
BellowsVoice는 조각을 하나씩 요청해. profile_id: pippa, target: cache, source: pathfinder. 이 중 둘엔 옆 주석에 사연이 붙어 있어. 기본 target은 합성한 소리를 사무실 스피커로 틀어 버리고, cwkpippa로 시작하는 source는 사흘 뒤에 만료돼. 조각은 하나씩 따로 합성하고, 앞뒤 말에 이어 붙이지 않아. 주변 단어에 기대는 녹음은 다시 쓸 수가 없으니까.
받은 소리는 폰 안의 ChunkCache에 쌓여. Bellows도 캐시가 있긴 해. 근데 정책상 300개를 30일만 들고 있고, 사전을 고칠 때마다 키가 바뀌어. 그래서 차가 들은 건 차가 직접 갖고 있어. 용량 200MB, 키는 목소리 id와 텍스트를 이은 SHA-256. 목소리 id는 bellows:pippa: 뒤에 프로필의 현재 모델을 붙인 거라서, 모델이 바뀌면 옛 목소리를 새 목소리인 척 틀지 않고 그냥 캐시 미스가 나. 용량을 넘으면 도로랑 장소 조각만, 제일 오래 안 쓴 것부터 내보내. 문구, 거리, 숫자는 절대 안 내보내.
채우는 건 VoicePrefetcher야. 경로에서 안내가 시작될 때, 그리고 경로를 다시 잡을 때마다 PromptComposer가 그 경로에서 나올 수 있는 조각을 전부 뽑아. 회전 동작이 먼저야. 제시간에 나가야 하는 줄이니까. 그다음 경로 위 카메라, 그다음 닫힌 어휘. 캐시가 빠진 조각을 알려 주면 한 번에 셋씩 받아 와. 예산은 6,000자고, 연달아 세 번 실패하면 멈추고, 이미 받으러 간 조각은 두 번 청하지 않아. 합성 한 번 한 번이 다 돈이거든.
말하는 단위가 아니라 되풀이되는 단위를 캐시해. 듣는 사람한테 닿는 건 문장이지만, 세상이 되풀이하는 건 조각이야. 문법에 이미 있는 이음매에서 자르면 평범하게 쓰기만 해도 캐시가 저절로 차. 아빠가 예상한 "쌓임"이 딱 그거야.
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 }
}
안내문과 PathfinderKit의 VoiceTests가 기대하는 조각 (테스트의 기댓값을 그대로 옮겨 읽기 좋게 배치)·text
회전교차로에서 두 번째 출구로 나가서 Calle de Alcalá(으)로 진입
-> [회전교차로에서] [두 번째 출구로 나가서] [Calle de Alcalá로] [들어가]
한남IC에서 계속 이동하여 경부고속도로(으)로 진입
-> [한남IC에서] [쭉 가다가] [경부고속도로로] [들어가]
한남대교남단에서 강남대로 압구정역.한강공원(잠원), 부산 방면으로 오른쪽 차선 유지
-> [한남대교남단에서] [강남대로] [압구정역] [한강공원] [잠원] [부산 방면으로] [오른쪽 차선 유지해]
Toledo, Atocha 방면 14번 출구로 나가기
-> [Toledo] [Atocha 방면] [14번] [출구로 나가]
최소 스케치: 애플의 "(으)로"를 마지막 소리로 로/으로 중에 고르기 (swiftc로 컴파일해서 돌렸어, 출력은 맨 아래)·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로 진입
네 앱이 평범한 한 주 동안 말하거나 알림으로 띄우는 문장을 전부 모아 봐. 경고, 확인, 상태 문구까지. 각 문장을 문법의 이음매에서 쪼개. 고정 문구, 단위가 붙은 숫자, 열린 이름으로. 통째 문장이 서로 다른 게 몇 개인지, 서로 다른 조각이 몇 개인지 세 봐. 그다음 조각 중에 처음 쓰기 전에 한 번 받아 둘 수 있는 닫힌 집합과, 쓸수록 늘어나기만 하는 열린 집합을 표시해 봐.
Hint
두 개수의 비율이 조각 캐시가 남는 장사인지 알려 줘. 이음매를 조심해. 앞 단어에 따라 모양이 바뀌는 조사나 관사(한국어 로/으로, 영어 a/an, 프랑스어 de/d')는 이웃한 이름 조각에 붙이든가, 자르기 전에 먼저 풀어 둬야 해.
Progress
Progress is local-only — sign in to sync across devices.