목소리는 둘, 오디오 경로는 하나
둘째 날, Pathfinder한테 목소리가 하나 더 생겼어. 아빠가 내비 안에 피파를 넣어 달라고 했거든.
피파까지 심어진 차/도보 내비 앱이어야 해. 내비 도중 피파 보이스모드 가능해야 하고. 운전/도보 중 피파랑 자연스러운 대화가 가능한.
바로 전날엔 피파 음성 모드를 안정적으로 지키려고 내비를 피파 앱 밖으로 빼기로 정했어. 그래서 길을 피파 앱으로 데려간 게 아니라, 대화를 길로 데려왔어. 아키텍처 문서가 이유를 한 줄로 적어 놨어. 오디오 경로 위에 앱이 하나여야만 안내와 피파 목소리가 서로 겹치지 않고, 마이크가 안내를 아빠 목소리로 잘못 듣지 않는다.
앱이 둘이면 각자 오디오 세션을 쥐고 서로 사정을 몰라. 내비는 "피파가 지금 말하는 중이야?"를 물어볼 데가 없고, 대화 쪽은 차 안에 울리는 목소리가 아빠가 아니라 회전 안내라는 걸 알 길이 없어. 한 프로세스 안이면 두 질문 다 답이 있어.
대화한테 알려 줘야 하는 이유
피파 대화는 마이크와 같은 오디오 엔진에서 재생돼. CwkKitVoice가 그 엔진에 Apple의 음성 처리를 켜고, 에코 캔슬러는 그 엔진이 내보내는 소리를 기준으로 삼아 빼. 그래서 마이크 입력에서 피파 목소리가 빠져. 안내는 그 엔진 밖에 따로 있는 플레이어 VoicePlayer에서 울려. Pathfinder 아키텍처 문서는 딱 잘라 적어. 마이크의 에코 캔슬링은 그 플레이어 소리를 못 듣는다고. 이건 이 구성이 깔고 가는 설계 전제지, 에코 캔슬링이 원래 그렇다는 법칙은 아니야. 기기마다 Apple 처리가 두 번째 플레이어 소리를 얼마나 잡아 줄지는 앱이 재지도 않고 기대지도 않아. 대화가 캔슬러만 믿었다가 놓치면, 대화의 귀가 "300미터 앞에서 좌회전해"를 아빠 말로 받아 적고, 끼어들기 감지기는 그걸로 피파 말을 끊어 버릴 거야.
그래서 대화가 안내 목소리의 문지기야. VoicePlayer는 줄마다 재생 전에 GuidanceVoiceGate.guidanceLineArrives한테 지금 틀어도 되는지 묻고, 줄이 시작하고 끝날 때 guidanceLine(playing:)으로 알려. 둘 다 PippaTalk이 구현해. 근데 따르는 규칙은 앱 안에 없어. PathfinderKit의 순수 함수 TalkInterplay에 있고, 오디오 장치 하나 없는 Mac에서 테스트로 묶여 있어.
표 하나
| 대화 단계 | 줄의 우선순위 | 행동 |
|---|---|---|
| off, paused, stopped | 무엇이든 | speak: 평소처럼 재생 |
| connecting, listening, thinking | 무엇이든 | speakOverListening: 바로 틀고, 끝날 때까지 대화의 귀를 닫아 |
| speaking | 정보 | drop: 상태 문구로 피파 말을 끊을 가치는 없어 |
| speaking | 긴급 | cutHerThenSpeak: 피파는 그 자리에서 멈춰 |
| speaking | 안내 | 지금 말하는 구간이 3초 안에 끝나면 waitForHer, 아니면 cutHerThenSpeak |
3초는 상한이고, 코드 주석이 근거를 대. 시속 100킬로에서 3초면 80미터쯤이라 "1킬로"나 "300미터"를 반올림하는 폭 안이라는 거야. 따라 말하기 전에 산수부터 해 봐. 붙잡아 둔 줄은 처음 지을 때의 단어를 그대로 들고 있어. 시속 100킬로에서 "300미터" 줄이 3초를 꽉 채워 기다리면(83미터쯤) 남은 거리는 217미터 남짓이야. SpokenDistance라면 이제 200미터라고 말할 거리지. 1,000미터에서 지은 "1킬로미터" 줄은 917미터쯤에서 나가는데, 이건 "900미터"로 읽힐 거리야. 그러니까 기다림은 반올림 한 칸을 치를 수 있고, 틀리는 방향은 늘 같아. 줄이 남은 길보다 조금 더 길게 말해. 그래도 괜찮은 건 절대 안 기다리는 줄이 있어서야. 긴급 줄("잠시 후", "속도 줄여")은 곧장 끊고 들어가. 존재 이유가 지금이니까. 고속도로 계획에선 300미터 단계가 바로 그 긴급한 "잠시 후"라서, 회전이 코앞이라는 줄은 기다리는 일이 없어. 그리고 "3초 안에"는 플레이어가 피파 구간을 끝까지 디코딩했을 때만 알 수 있어. 아직 받아 오는 중이면 herRemaining이 nil이고, 안내 줄은 끊고 들어가.
답을 받은 앱이 하는 일
- 귀 닫기. 안내 줄이 울리는 동안, 그리고 끝나고 0.5초 더(
earsAfterGuidance, 차 안에 아직 울림이 남아서) 마이크 조각을 듣는 쪽에 아예 안 넘겨. 끼어들기 판정도 길을 상대로는 절대 안 해. - 기다리기. 붙잡아 둔 줄은 더 새 줄이 오면 바뀌어. 중요한 건 늘 제일 새 줄이니까. 피파 구간이 끝나면 붙잡은 줄이 피파의 다음 구간보다 먼저 나가. 이 약속 뒤엔 3.5초짜리 마감이 받치고 있어. 피파 구간이 말한 것보다 길어지면 피파를 끊고 줄을 틀어.
- 끊기. 피파는 그 자리에서 멈추고, cwkPippa는 아빠가 어디까지 들었는지 전달받아(음성 모드의
heard_until이야. 다음 대답은 거기서 이어져). 대화는 곧바로 다시 듣기 시작하는데, 안내 줄 때문에 귀는 여전히 닫혀 있어. 진행 중이던 끼어들기 확인도 취소해. 길은 아빠가 말하는 게 아니니까. - 피파의 다음 구간. 다음 구간이 준비됐는데 안내 줄이 울리는 중이면, 그 줄이 끝날 때까지 기다려.
- 세션. 대화가
.playAndRecord로 오디오 세션을 쥐고 있으면, 안내 플레이어는 카테고리를 건드리지 않고 그 안에서 재생해.
같은 문이 제공자 목소리에도 걸려. 구글이 회전을 말하는 중이라도 대화가 마이크를 쥐고 있으면 구글 목소리는 꺼져. 그래야 소리로 나온 도로 이름이 아빠 말인 척 피파 대화에 적히는 일이 없어.