본문 바로가기
C.W.K.
Stream
Lesson 03 of 05 · published

길이 먼저야

~14 min · voice, audio, arbitration, pure-functions, swift

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

목소리는 둘, 오디오 경로는 하나

둘째 날, 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로 오디오 세션을 쥐고 있으면, 안내 플레이어는 카테고리를 건드리지 않고 그 안에서 재생해.

같은 문이 제공자 목소리에도 걸려. 구글이 회전을 말하는 중이라도 대화가 마이크를 쥐고 있으면 구글 목소리는 꺼져. 그래야 소리로 나온 도로 이름이 아빠 말인 척 피파 대화에 적히는 일이 없어.

누가 양보할지는 정책이고, 정책은 순수 코드에 둬. 오디오 배관(세션, 플레이어, 소켓)은 테스트하기 어렵고 미묘하게 틀리기 쉬워. 누가 말할지 정하는 결정은 다섯 줄짜리 표야. Pathfinder는 그 표를 부작용 없는 함수에 넣고, 앱은 답을 실행만 하게 했어. 그래서 아빠 안전이 걸린 규칙이 바로 테스트가 붙은 부분이야.

Code

cwkPathfinder, PathfinderKit/Sources/PathfinderKit/Talk/TalkPhase.swift: TalkInterplay.maxWait와 action (원문 그대로, 64-91행)·swift
  /// The longest a guidance line waits for her stretch to end. At 100 km/h
  /// that is 80 m, inside the rounding of "1 km" or "300 m"; an urgent line
  /// ("잠시 후", "속도 줄여") never waits.
  public static let maxWait: TimeInterval = 3

  /// `herRemaining` is how much of her current stretch is left to play, when
  /// the player knows (its whole take decoded); nil while it is still
  /// arriving.
  public static func action(
    priority: Utterance.Priority, phase: TalkPhase, herRemaining: TimeInterval? = nil
  ) -> Action {
    switch phase {
    case .off, .paused, .stopped:
      return .speak
    case .connecting, .listening, .thinking:
      return .speakOverListening
    case .speaking:
      switch priority {
      case .info:
        return .drop
      case .urgent:
        return .cutHerThenSpeak
      case .guidance:
        if let herRemaining, herRemaining <= maxWait { return .waitForHer }
        return .cutHerThenSpeak
      }
    }
  }
cwkPathfinder, clients/apple/PathfinderMobile/Sources/Talk/PippaTalk.swift: guidanceLineArrives(_:) (원문 그대로, 1087-1122행)·swift
  func guidanceLineArrives(_ line: Utterance) -> Bool {
    var remaining: TimeInterval?
    if let progress = readingProgress(), progress.ended {
      remaining = max(0, progress.decoded - progress.played)
    }
    switch TalkInterplay.action(priority: line.priority, phase: phase, herRemaining: remaining) {
    case .speak, .speakOverListening:
      return true
    case .drop:
      return false
    case .waitForHer:
      // The newest line is the one that matters; an urgent one never waits.
      heldLine = line
      heldDeadline?.cancel()
      heldDeadline = Task { [weak self] in
        try? await Task.sleep(for: .seconds(TalkInterplay.maxWait + 0.5))
        guard !Task.isCancelled, let self, self.heldLine != nil else { return }
        // Her stretch ran longer than it said: the road does not wait more.
        if self.phase == .speaking {
          self.cutInHoldStore?.cancel()
          self.cutIn()
          self.listen()
        }
        self.releaseHeldLine()
      }
      return false
    case .cutHerThenSpeak:
      // She stops where she is; the talk listens again at once, and the
      // closed ears keep the line out of what it hears. A cut-in being asked
      // about is dropped: the road is not Dad talking.
      cutInHoldStore?.cancel()
      cutIn()
      listen()
      return true
    }
  }

External links

Exercise

출력 하나를 두고 두 가지가 다투는 앱을 하나 골라 봐. 미디어 위로 울리는 알림음, 게임의 효과음 위로 깔리는 대사, 통화 중에 오는 알림, 로그 하나에 쓰는 두 작성자. 그 중재를 순수 함수로 써 봐. 들어오는 건 각 소스의 상태와 새로 온 쪽의 우선순위, 나가는 건 행동 하나(재생, 대기, 끊기, 버리기). 생각 안 해 본 칸까지 표를 다 채우고, 칸마다 테스트를 하나씩 써.
Hint
건너뛴 칸에 버그가 살아. 새로 온 게 지연된 뒤에도 값어치가 있는지 물어봐. 2초 지나면 뜻을 잃는 거면 못 기다려. 상태 문구면 버려도 돼. 기다림에 한도를 두려면 그걸 실행하는 쪽에 마감이 있어야 해. 상대가 "곧 끝나"라고 한 약속은 틀릴 수 있으니까.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.