~13 min · voice, audio, fallback, avfoundation, swift
Level 0길 잃음
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
한 줄이 나갈 차례가 오면
미리 받아 두기는 바람일 뿐이야. 진짜 시험은 VoicePlayer.start에서 벌어져. 한 줄이 나갈 차례가 되고, 플레이어가 캐시한테 그 줄을 달라고 하는 순간. ChunkCache.line의 대답은 둘 중 하나야. 줄의 조각 전부, 아니면 아무것도. 조각을 순서대로 훑다가 디스크에 없는 게 하나라도 나오면 그 자리에서 nil로 끝내. 사용 횟수도 실제로 재생될 줄에만 세고.
전부 모이면 playPippa로 가. 녹음 하나하나를 풀고, 앞뒤에 붙은 침묵을 잘라 내고, AVAudioPlayerNode 하나에 빈틈없이 줄줄이 예약해. 그래서 이음매가 샘플 단위로 딱 맞고, 녹음 다섯 개가 한 줄을 1초씩 늘이지도 않아. nil이 오면 줄 전체가 ko-KR 목소리로 AVSpeechSynthesizer에 넘어가. 빠진 조각은 다음번엔 미리 받아 둘 수 있도록 보고되고. 다 모였는데도 떨어질 수 있어. 녹음 하나를 못 읽거나 두 녹음의 오디오 형식이 다르면 playPippa가 false를 돌려줘. 그럼 반쪽만 틀지 않고 줄 전체를 시스템 목소리로 보내.
빈 데만 시스템 목소리로 메우면 안 돼?
솔깃한 최적화가 눈에 보이지. 있는 조각 넷은 피파 목소리로 틀고, 없는 도로 이름 하나만 시스템 목소리한테 읽히는 거. 불변식 6번이 한 문장으로 막아. 말할 때 조각이 하나라도 빠진 줄은 통째로 시스템 목소리로 말한다. 한 줄 안에서 두 목소리는 절대 안 섞인다.
이유는 시속 100킬로로 달리면서 듣는 방식에 있어. 한 줄은 한 사람이 하는 한 마디야. 중간에 음색이 바뀌면 귀는 다른 사람이 끼어들었다고 들어. 그럼 "300미터 앞에서 왼쪽"에 가야 할 주의가 방금 누가 말했나로 새. 처음부터 끝까지 밋밋한 목소리가 구멍 뚫린 예쁜 목소리보다 알아듣기 쉬워. 목소리가 하나라는 것 자체가 전달 내용의 일부야.
이 규칙 덕에 실패도 읽을 수 있어. 줄마다 운전 기록에 사건 하나가 텍스트와 함께 남아. 피파 목소리였으면 spoke, 아니었으면 voice_fallback. 대체가 도로 이름에 몰려 있으면 미리 받기가 덜 끝난 거고, 고정 문구에서 대체가 나면 닫힌 어휘가 아예 안 온 거야. 어느 쪽이든 기록이 말해 주고, 빠진 조각은 이미 받으러 가는 중이고.
우선순위와 오디오 세션
줄마다 우선순위가 셋 중 하나 붙어. 긴급 줄(회전 직전의 "잠시 후", 카메라 앞의 "속도 줄여")은 덜 급한 걸 끊고 들어가. 안내 줄은 제 차례를 기다려. 정보 줄("다시 경로로 들어왔어")은 이미 기다리는 게 있으면 버려. 세션은 .playback에 모드는 .voicePrompt, 옵션은 .duckOthers랑 .interruptSpokenAudioAndMixWithOthers. 아빠 음악은 안내 밑으로 낮아졌다가 끝나면 돌아오고, 팟캐스트 같은 말소리 오디오는 피파 위로 겹치지 않게 잠깐 멈춰.
플레이어는 줄마다 세션을 다시 잡아. 지난 줄 이후에 전화나 피파 대화가 세션을 가져갔을 수 있으니까. "한 번 켰음"만 기억하는 플래그 때문에 통화 뒤에 엔진이 시작을 거부한 적이 있거든. 전화가 끼어들면 그 줄은 날아가고, 미디어 서버가 재설정되면 엔진과 노드를 새로 만들어. 모드는 아빠가 설정에서 골라. 피파 목소리, 시스템 목소리, 무음. 시뮬레이션 주행은 언제나 무음이야. 그래서 시험 경로는 말도 안 하고 합성 비용도 안 써.
한 단계 위에서도 같은 규칙: 여정 전체
해외에선 구글 Navigation SDK가 자기 경로를 직접 안내하고, Pathfinder는 구글의 회전 피드를 따라가. 기본값은 이래. 회전마다 피파가 자기 말로 말해. 회전 모양과 거리로 문장을 만들고, 도로 이름은 빼. 구글 약관이 도로 이름은 구글 자기 목소리 몫으로 남겨 두거든. 설정 하나를 켜면(기본은 꺼져 있어) 회전 안내를 구글 목소리한테 넘겨. 도로 이름까지 다. 그때 피파는 그 위로 한마디도 안 해. 구글의 회전 피드가 끝내 안 오면 피파가 만들 수 있는 줄이 없어. 그럼 구글 목소리가 여정을 맡고, 화면과 운전 기록에 그 이유가 남아.
구글 목소리는 Pathfinder 플레이어 바깥에서 울려. 그래서 앱이 플레이어를 조용히 시키는 세 가지를 구글한테도 따로 전해. 아빠의 음소거, 마이크를 쥔 피파와의 대화, 그리고 큰 건물 입구까지 가는 마지막 구간. 거기선 제공자의 회전 안내가 다른 층 얘기거든. 규칙은 한 줄에서 여정 전체로 그대로 커져. 여정 하나를 두 목소리가 안내하는 일은 없고, 말없이 아무도 안내 안 하는 일도 없어.
좋은 방식으로 전부 못 하겠으면, 평범한 방식으로 전부 해. 품질이 반쪽이면 그게 이음매가 되고, 듣는 사람은 내용보다 이음매를 먼저 들어. 대체는 그 줄을 깎아 내리는 게 아니야. 그 줄을 온전히 전하고, 다음번엔 좋은 방식이 준비되도록 기록에 한 줄 남기는 거야.
Code
최소 스케치: 전부 아니면 전무의 판정, 그리고 빠진 조각을 받은 뒤의 두 번째 시도 (swiftc로 컴파일해서 돌렸어, 출력은 맨 아래)·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
/// 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)
}
네 코드에서 결과 하나를 여러 부분으로 조립하는데, 부분마다 실패할 수 있거나 출처가 다른 곳을 찾아봐. 캐시 조각과 실시간 조각으로 짓는 페이지, 서비스 두 개를 합친 보고서, 문자열이 빠진 번역 화면 같은 데. 지금 부분 하나가 빠지면 사용자가 뭘 보는지 적어. 그다음 전부 아니면 전무 규칙을 정하고 테스트를 써. 모든 부분이 좋은 출처에서 오든가, 결과 전체가 평범한 출처에서 오든가. 그리고 빈틈은 기록해서 다음번엔 좋은 출처가 채우게.
Hint
읽거나 듣는 사람한테 일관성의 단위가 뭔지 물어봐. 문장, 표의 한 행, 화면 하나는 보통 한 단위야. 서로 독립된 항목의 목록은 보통 아니고. 한 단위 안에서 출처가 섞이면 그게 고장 난 걸로 읽혀.
Progress
Progress is local-only — sign in to sync across devices.