~15 min · swift, navigation, voice, rounding, testing
Level 0길 잃음
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
회전 하나에 세 번
좋은 조수석은 중계를 안 해. 차선 바꿀 여유가 있을 때 한 번, 가까워지면 또 한 번, 바로 앞에서 마지막으로 한 번. Pathfinder는 이걸 그대로 AnnouncementStage에 담았어. early, prepare, imminent. 순서가 있어서 엔진이 서로 비교할 수 있어.
언제 울릴지는 속도에 따라 달라. 시내 계획은 1,000m, 300m, 90m. 다듬은 속도가 19.4m/s(시속 70km)에 닿으면 고속도로 계획으로 바꿔. 2,000m, 1,000m, 300m. 시속 100km면 시내 계획의 300m 안내는 11초쯤밖에 안 남기거든. 여기서 쓰는 속도는 다듬은 값이야. 새 측정이 40%, 이전 추정이 60%. 그래서 튀는 측정 하나가 들어와도 추정은 그쪽으로 일부만 끌려가. 흔들림을 줄여 줄 뿐, 전환을 막아 주진 않아. 문턱 근처에선 측정 하나로도 계획이 바뀌어. 추정이 19m/s일 때 21m/s가 하나 들어오면 19.8m/s가 되고, 되돌아오지 않게 붙잡는 이력(hysteresis) 구간도 없어. 그래도 이런 전환은 싸게 끝나. 다음 절의 규칙 덕분이야. 한 번 말한 단계는 다시 말하지 않으니까, 전환은 다음 안내를 앞당길 수는 있어도 피파가 같은 말을 되풀이하게 만들 순 없어.
early와 prepare엔 거리가 붙고, imminent엔 안 붙어. "잠시 후"라고 말하고, 긴급 우선순위로 나가. 덜 급한 문장이 재생 중이면 끊고 들어갈 수 있다는 뜻이야. 음성 시험에 있는 실제 문장 하나 볼까. 300미터 앞에서 + 백마로로 + 우회전해. 피파 목소리 캐시 조각 세 개야.
시점마다 한 번, 그리고 제일 늦은 것만
엔진은 작은 맵 announced를 들고 있어. 단계 번호마다 이미 말한 가장 높은 시점을 적어 두는 거야. 위치가 경로에 맞을 때마다 다음에 말할 단계를 찾고, 차가 어느 구간에 들어와 있는지 따져서, 그 구간이 이미 말한 것보다 높을 때만 말해. 규칙 하나에서 동작 셋이 나오고, 셋 다 시험이 있어.
시점마다 한 번. 시내에서 회전을 지나가면 그 회전에 대해 early, prepare, imminent가 나오고, 두 번 나오는 건 없어.
공백 뒤엔 제일 늦은 것만. 터널이 위치를 삼켰다가 다음 위치가 회전 80m 앞에서 왔다고 쳐. 차는 한 번에 문턱 둘을 넘은 거야. 말하는 건 imminent 하나뿐. 80m 앞에서 "1킬로미터 앞"이라고 하느니 입 다무는 게 나아.
구간 안에서 출발하면 반복 안 해. 시작 문장("안내 시작할게 …")이 이미 다음 동작을 실제 거리로 말해. 그래서 회전 600m 앞에서 주행을 시작하면 그 구간은 말한 걸로 쳐. prepare와 imminent는 그대로 이어지고.
동작 하나를 막 끝냈는데 다음 동작이 벌써 early 구간 안일 수도 있어. 그럼 바로 실제 거리로 말해. 문서 주석이 예로 든 "then, in 600 m ..." 같은 경우야.
붙어 있는 회전은 이어서
100m 간격 동작 두 개는 각자 안내를 받을 수가 없어. 첫 번째를 끝낼 즈음 두 번째가 이미 벌어지고 있거든. 그래서 prepare 이후 시점에서 다음 동작이 chainDistance(180m) 안에 있으면 "이어서"로 붙여 말해. 잠시 후 우회전해 이어서 좌회전해. early에선 절대 안 붙여. 1km 밖에서 두 번째 회전은 아직 쓸모 있는 정보가 아니니까.
말하는 거리는 잰 거리가 아냐
prepare 문턱이 울리는 순간 차는 회전에서 287m쯤 떨어져 있을 수 있어. 위치는 1초에 하나 오고, 그 사이에 차는 12m를 가니까. "287미터"를 듣고 싶은 사람은 없어. SpokenDistance.from(metres:)가 잰 값을 말할 값으로 반올림해.
잰 거리
말하는 단위
예
75m 미만
10m 단위, 최소 10
43 → 40미터
75m 이상 950m 미만
100m 단위, 100~900
149 → 100미터, 287 → 300미터
950m 이상 2,900m 미만
0.5km 단위, 최소 1
1,260 → 1.5킬로미터
2,900m 이상
1km 단위
7,420 → 7킬로미터
NaN, 무한대, 표식 값
"잠시 후"
변환 안 함
마지막 줄은 취향 문제가 아니라 크래시를 막으려고 둔 거야. 제공자의 "경로 없음" 표식 값이나 NaN을 Int로 바꾸면 프로그램이 멈춰. 반올림엔 덤도 있어. 계획이 말할 수 있는 거리는 작고 닫힌 집합이야. 그래서 문장 조립기가 "고정 문장이랑 계획이 말할 수 있는 모든 거리"를 목록으로 뽑을 수 있고, 그걸 전부 첫 주행 전에 피파 목소리로 받아 둘 수 있어.
도착, 그리고 그 뒤 8초
목적지도 단계 하나라서 early, prepare, imminent를 다 받아. 엔진이 arrived를 선언하는 조건은 둘 중 하나야. 남은 선이 35m에 위치 정확도(최대 25m까지 셈)를 더한 거리 안이거나, 선의 90%를 지났고 목적지 핀에서 35m 안이거나. 경로는 도로 가에서 끝나는데 핀은 주차장 안에 있을 수도 있거든. arrived는 한 번만 말해. 그 뒤로 update는 아무것도 안 돌려줘.
앱은 그다음 8초 뒤에 주행을 끝내. 한 번만, 도착한 그 주행만. 예전엔 도착 뒤 위치마다 종료를 예약해서, 늦게 도착한 예약이 다음 주행을 끝내거나 새 미리보기를 지워버릴 수 있었어. 코드 리뷰가 잡은 버그야. 엔진은 처음부터 맞았어. 사건 하나에 딱 한 번만 반응하는 법은 껍질이 배워야 했던 거지.
잴 땐 정밀하게, 말할 땐 굵게. 상태엔 미터 하나까지 다 담고, 반올림은 단어를 고르는 순간에만 해. 반올림은 목소리 쪽 결정이고 상태는 안내 쪽 결정이야. 둘이 숫자 하나를 같이 쓰면 안 돼.
Code
GuidanceEngine.swift: 시내 계획과 고속도로 전환 (원문 발췌)·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(), 시점마다 한 번, 이어서 붙이기 (원문 그대로)·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)
}
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 잠시 후
네 제품이 사람한테 보여주거나 말하는 숫자 하나를 골라봐. 파일 크기, 남은 시간, 거리, 가격 변동. 정밀한 값을 보여줄 값으로 바꾸는 함수를 SpokenDistance처럼 구간을 명시해서 짜. 그리고 구간 경계마다 양쪽 값을 하나씩 넣고 NaN과 무한대까지 넣은 시험 표를 만들어. 그다음 그 함수가 낼 수 있는 서로 다른 출력이 몇 개인지 세봐.
Hint
서로 다른 출력 수에 끝이 없으면, 그건 자릿수만 줄인 측정값이야. 작고 닫힌 집합이어야 모든 출력을 미리 계산하고, 캐시하고, 번역할 수 있어. Pathfinder가 주행 전에 피파의 거리 문장을 받아 둘 수 있는 것도 바로 그 성질 덕분이야.
Progress
Progress is local-only — sign in to sync across devices.