데보션앱 소개페이지 바로가기
로그인 선택

신고하기

CLOSE
신고사유 (대표 사유 1개)
상세내용 (선택)
0/200
  • 신고한 게시글은 더 이상 보이지 않습니다.
  • 이용약관과 운영정책에 따라 신고사유에 해당하는지 검토 후 조치됩니다.
  • 허위 신고인 경우, 신고자의 서비스 이용이 제한될 수 있으니 유의하시어 신중하게 신고해 주세요.
(이 회원이 작성한 모든 댓글과 커뮤니티 게시물이 보이지 않고, 알림도 오지 않습니다.)

미리보기

커뮤니티

      1,234

      badge 23.06.15

      글 등록

      카테고리를 선택해주세요.

      DEVOTEE를 활성화 시키면
      지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.

      버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.

      임시저장함에 저장되었습니다. 저장일시 : 2022.5.17 14:29:08

      임시저장함

      제목을 선택하시면 이어서 작성이 가능하며,
      최대 20건까지 저장합니다.
      컨텐츠 유형, 제목, 저장일시, 삭제로 이뤄진 임시저장 목록
      컨텐츠 유형 제목 저장일 삭제

      데보션 블로그 게재 요청

      CLOSE
      • *
      • *

      본인인증

      효율적인 데보션 서비스 이용 및
      고객님의 소중한 개인정보보호를 위해
      본인인증을 진행해주세요. 본인인증 미 진행 시 로그인이 제한됩니다.
      본인인증 실패

      본인인증 로그인에 실패하였습니다.
      회원이 아니시거나 본인인증 등록이
      완료되지 않은 사용자입니다.

      회원정보 연결

      CallKit에서 AVAudioSession 사용하기

      rutinfc 24.10.15
      2,325 8 0
      DEVOTEE 요약
      본 블로그는 iOS에서 mVoIP 서비스 개발을 위한 CallKit과 AVAudioSession에 대한 설명을 제공합니다. CallKit을 사용하여 오디오 설정을 관리하는 방법과 다양한 오디오 이슈를 해결하기 위한 접근 방식을 다루며, 특히 AVAudioSession의 카테고리와 모드 설정에 중점을 둡니다. 이 글은 애플의 샘플 코드를 바탕으로 중요한 개념과 해결 방법을 설명하며, 적절한 오디오 경험을 제공하기 위한 다양한 사항들을 강조합니다.
      DEVOTEE 추천 블로그

      시작하기 앞서

      이 글은 mVoIP 서비스를 준비하면서 CallKit을 통해 오디오를 사용하는 개발자를 대상으로 작성되었습니다.

      iOS에서 VoIP를 구현하기 위해서는 PushKit과 CallKit에 대한 배경지식이 필요합니다.

      특히 CallKit은 CXCallController와 CXProvider의 역할에 대한 이해가 중요하며, 이와 관련된 자세한 내용은 Apple 공식 문서를 참고하면 많은 도움이 될 것입니다.

      애플 CallKit 기술문서


      그러나 CallKit에서 사용되는 오디오에 대해서는 자세히 설명된 자료를 찾기가 쉽지 않습니다. 이 글에서는 CallKit에서 사용되는 오디오 내용을 중점적으로 다루고자 합니다.

      iOS에서 AVAudioSession은 앱의 오디오 사용 시 하드웨어를 제어하는 중개자 역할을 합니다.

      일반적으로 AVAudioSession은 주로 영상이나 음악 재생, 또는 마이크 녹음 시 오디오 활성화에 사용됩니다.

      (AVPlayer, AVAudioPlayer을 사용하기 전 AVAudioSession으로 환경을 설정하고 활성화)

      하지만 CallKit과 mVoIP 환경에서의 AVAudioSession 사용은 단순 음원 재생이나 녹음과 다른 환경 설정과 활성화 방식이 필요합니다.

      • 예를 들어, mVoIP에는 playAndRecord 카테고리를 사용해야 하며, 모드는 voiceChat이나 videoChat을 사용해야 합니다.

      • 오디오의 활성화/비활성화는 CallKit에 의해 관리됩니다.

      오디오 환경 설정과 활성화 방식을 제대로 이해하지 못한 채 사용하다 보면 예상치 못한 이슈가 발생할 수 있습니다.

      전화의 특성상 대부분 중요한 문제(무음이나 통화 품질 저하)였고, 이를 해결하기 위해 많은 시행착오를 겪었습니다.


      CallKit에서 발생하는 오디오 이슈 현상

      통화 중

      • 수신자나 발신자의 소리가 들리지 않음

      • 기본 수화기와 스피커가 강제 전환되는 현상

      • 통화 종료 후 다음 통화가 비정상적으로 동작하는 현상

      다른 앱에서 오디오 사용 중 (음원 재생이나 녹음 중)

      • 전화 수신 시 다른 앱의 음원 믹싱 현상

      • 전화 수신 시 입출력 포트 경로가 강제로 변경되는 현상

      • 전화 종료/거절 시 이전에 재생되던 음원이 이어서 재생되지 않는 현상

      다양한 환경(블루투스, 카플레이, 이어셋 등)에서의 호환성

      • 카플레이(CarPlay) 환경에서 통화는 진행되지만, 마이크가 동작하지 않는 현상

      • 블루투스 인포메이션 화면에 통화 종료 이후에도 통화 중 화면이 노출되는 현상

      • 블루투스로 통화 종료 시 통화 종료음 볼륨 문제

      • 에어팟으로 통화 중 연결이 끊기고 스피커나 기본 수화기로 통화가 전환되는 현상

      애플에서 제공하는 샘플 코드를 참고하여 이 이슈를 해결하기 위해 필요한 핵심 개념들을 설명하겠습니다.


      준비사항

      애플에서 제공한 SpeakerBox 샘플 코드를 바탕으로 설명하겠습니다.

      이 샘플은 CallKit, AVAudioSession, 그리고 AVAudioEngine을 활용하여 마이크로 입력된 음성을 아이폰의 내장 수화기로 출력하는 간단한 예제입니다.

      이 설명에서는 오디오 환경 설정 방법과 각 인터페이스 호출 시 주의해야 할 점을 중점적으로 다루겠습니다. 특히 다음과 같은 중요한 사항을 살펴보겠습니다:

      1. AVAudioSession의 적절한 구성 방법

      2. 통화 시작 및 종료 시 AVAudioSession 활성/비활성화 처리

      3. 인터럽트 처리 및 오디오 라우팅 변경 관리

      이를 통해 mVoIP 앱 개발 시 발생할 수 있는 일반적인 문제들을 피하고, 안정적인 오디오 경험을 제공하는 방법을 이해하실 수 있을 것입니다. 샘플코드

      • SpeakerBox 샘플프로젝트가 공개된 이후 몇 번의 업데이트를 통해 소스코드가 조금씩 변경되어 왔습니다.

        • UIKit → SwiftUI, AudioComponents → AVAudioEngine

      • Apple Watch에서 사용되는 CallKit 동작 방식도 같이 포함되어 있습니다.

      • 샘플프로젝트를 Github 검색하면 이전의 샘플 코드를 확인 할 수 있습니다.

      • 샘플 코드에서 AVAudioSession을 담당하는 클래스는 AudioController.m Objective-C 코드입니다.

        하지만 Objective-C 코드에 익숙하지 않은 분과 가독성을 위해 Swift 코드로 전환하여 설명하겠습니다.

      • 첨부파일 참조 (AudioController.swift)


      오디오 초기화

      AudioController가 최초 초기화 시점에 AVAudioSession 설정과 오디오 IOUnit을 설정합니다.

      private func setupAudioChain() {
      	self.setupAudioSession()
      	self.setupIOUnit()
      }

      setupAudioSession에서 호출되는 내용은 아래와 같습니다.

      private func setupAudioSession() {
          let session = AVAudioSession.sharedInstance()
          do {
              try session.setCategory(.playAndRecord)
              try session.setMode(.voiceChat)
              try session.setPreferredIOBufferDuration(0.005)
              try session.setPreferredSampleRate(44100)
          } catch {
              print("setupAudioSession Error : \(error)")
          }
          ....
      }

      setupAudioSession에서는 AVAudioSession의 카테고리를 설정하고 모드를 지정합니다.

      또한 preferredIOBufferDuration과 preferredSampleRate를 설정하는데, 이는 이후 설명할 AVAudioEngine 초기화 값과 일치해야 하므로 지정했습니다.

      하지만, 이 부분은 필수는 아닙니다.


      중요한 것은 카테고리와 모드 설정입니다.

      그리고 주의할 점은 일반적인 AVAudioSession 사용과 달리 CallKit에서는 활성 시점을 사용자가 직접 지정하지 않는다는 것입니다.

      오디오의 활성화/비활성화 시점은 CallKit에 의해 결정되므로, 앱에서 이를 지정하면 예상치 못한 문제가 발생할 수 있습니다.

      카테고리를 playAndRecord로 설정하면 오디오 출력(스피커)과 입력(마이크)을 사용하겠다고 OS에 선언하는 것입니다.

      이 코드는 오디오가 활성화되지 않은 상태에서는 아무런 변화를 일으키지 않습니다.

      오디오가 활성화된 상태에서 카테고리를 설정하면 앱에서 사용할 수 있는 포트 목록이 변경되며, routeChangeNotification을 통해 앱에 변경 사항을 알립니다.


      다른 앱에서 우선순위가 높은 오디오를 사용 중일 때 카테고리를 설정하고 활성화하면 오류가 발생할 수 있습니다.

      예를 들어, CallKit을 통해 오디오를 점유하고 있는 상태에서 다른 앱이 음악 재생을 위해 playback 카테고리를 설정하고 AVAudioSession의 *setActive(true)*를 호출하면 오류가 발생하고 오디오가 작동하지 않습니다.

      대부분의 경우 playback, playAndRecord 카테고리에서는 default 모드를 사용합니다.

      하지만 특수한 상황에서는 그에 맞는 설정이 필요합니다. CallKit 상황에서 설정해야 하는 voiceChat 모드는 다른 앱이나

      연결 환경(예: 블루투스, 카 인포테인먼트 시스템 연결 등)에서 오디오를 어떤 방식으로 사용할지 알려주는 역할을 하므로 올바른 설정이 중요합니다.

      AVAudioSession구성


      Category

      playback

      playback 카테고리는 단말의 스피커를 통해 음원을 재생할 때 주로 사용됩니다. 이 카테고리의 주요 특징은 다음과 같습니다:

      • 기본적으로 단말 스피커를 통해 음원이 재생되며, 블루투스나 USB 헤드셋 연결 시 해당 장치로 자동 전환됩니다.

      • AVRoutePickerView를 활용하여 사용자가 원하는 오디오 출력 경로를 선택할 수 있습니다.

      • 오디오 입력은 지원되지 않습니다.

      • 무음 모드에서도 소리가 재생됩니다.

      • 앱이 백그라운드 상태일 때도 오디오 재생이 가능합니다. (단, Info.plist에 UIBackgroundModes 설정 필요)

      • '~Other'로 끝나는 카테고리 옵션을 사용하면 다른 앱의 오디오와 믹싱이 가능해집니다.

        이러한 특성으로 인해 playback 카테고리는 음악 재생 앱, 동영상 플레이어 등 오디오 출력에 중점을 둔 앱에서 사용됩니다.

      playAndRecorod

      playAndRecord 카테고리는 오디오의 재생과 녹음을 동시에 수행해야 할 때 사용됩니다. 이 카테고리의 주요 특징과 사용 시 주의 사항은 다음과 같습니다:

      • 블루투스나 AirPlay 등의 외부 기기로 오디오를 출력하려면 allowBluetooth, allowBluetoothA2DP 또는 allowAirPlay옵션을 지정해야 합니다.

      • CallKit과 함께 사용하고 모드가 voiceChat일 경우, 위 옵션들은 불필요합니다. 블루투스나 카플레이 연결 시 자동으로 처리됩니다.

      • 스피커를 기본 출력으로 설정하려면 defaultToSpeaker 옵션을 사용합니다. 단, voiceChat 모드에서는 이 옵션을 사용하지 않아야 합니다.

      • 내장 수신기와 스피커 간 전환이 필요한 경우, defaultToSpeaker 옵션 대신 AVAudioSession의 overrideOutputAudioPort 인터페이스를 사용하여 speaker 또는 none을 지정합니다.

      • 무음 모드에서도 오디오가 재생됩니다.

      • 백그라운드 실행을 지원합니다. (Info.plist에 UIBackgroundModes 설정 필요)

      • 마이크 사용 시 반드시 사용자의 권한을 요청해야 합니다.

        이러한 특성들을 고려하여 앱의 요구사항에 맞게 적절히 설정해야 합니다.

      ambient

      ambient 카테고리는 앱의 보조적인 오디오 요소를 관리하는 데 특화되어 있습니다. 이 카테고리의 주요 특징과 사용 사례는 다음과 같습니다:

      • 배경 음악, 게임 효과음, 알림음 등 앱의 주요 기능을 방해하지 않는 보조적인 사운드를 제공할 때 사용합니다.

      • 다른 앱의 오디오와 자연스럽게 믹싱되어, 멀티태스킹 환경에서 사용자 경험을 해치지 않습니다.

      • 앱이 백그라운드로 전환되면 오디오 재생이 자동으로 중단되어 시스템 자원를 절약합니다.

      • 기기가 무음 모드일 때는 소리가 출력되지 않아 사용자의 설정을 존중합니다.

      • 오디오 세션 우선순위가 낮아 전화나 다른 중요한 오디오 이벤트에 의해 쉽게 중단될 수 있습니다.

      • 주로 게임, 뉴스 앱, 소셜 미디어 앱 등에서 보조적인 사운드 효과를 위해 사용됩니다.

        ambient 카테고리를 사용할 때는 앱의 전반적인 사용자 경험을 고려하여 적절히 구현해야 합니다.

        예를 들어, 백그라운드 전환 시 자연스럽게 페이드 아웃되도록 하거나, 앱 복귀 시 사용자의 액션에 따라 오디오를 다시 시작하는 로직을 구현할 수 있습니다.

      soloAmbient

      • ambient와 동일한 용도로 사용되나 다른 앱과 믹싱이 되지 않고 단독으로 음원을 재생할 때 사용됩니다.

      • 무음 모드에서는 동작하지 않습니다.

      • 일반적으로 soloAmbient 카테고리로 앱이 시작합니다.

      record

      • 오디오 녹음 용도로만 사용됩니다.

      • 음원 재생은 불가능합니다.

      • 마이크 사용 시 마이크 권한을 요청해야 합니다.

      multiRoute

      • 오디오 입력과 출력을 다르게 설정하여 복잡하고 여러 포트 경로에 오디오 작업이 필요한 경우 사용합니다.

      • 일반적인 음악 재생보다는 복잡한 멀티미디어 환경에서 사용해야 합니다.

      • 자세한 내용은 아래 애플 기술 문서 참고 부탁드립니다.


      Options

      allowAirPlay

      • playback, multiRoute, playAndRecord 카테고리에서 AirPlay를 사용할 수 있습니다. AirPlay 대상 기기는 AVRoutePickerView를 사용하여 선택할 수 있습니다.

      • voiceChat 모드: CallKit을 사용하는 경우, AirPlay 오디오 재생이 불가능합니다. 이 경우 AirPlay 옵션을 설정해도 AirPlay 포트가 표시되지 않습니다.

      • videoChat 모드: AirPlay를 통한 오디오 재생이 가능합니다. (= default, gameChat)

      allowBluetooth, allowBluetoothA2DP

      • playback 및 multiRoute: 블루투스 장치가 연결되면 자동으로 활성화됩니다. 별도의 설정이 필요하지 않습니다.

      • playAndRecord: 연결된 블루투스 장치의 입력 지원 여부에 따라 HFP(Hands-Free Profile) 또는 A2DP 프로필로 동작합니다.

      • HFP는 주로 VoIP 모드에서 사용됩니다.

      • CallKit 통화 화면에서 블루투스 옵션이 설정되어 있으면 UI 버튼이 오작동할 수 있습니다.

      • voiceChat 모드에서는 A2DP 옵션을 설정할 수 있지만 정상적으로 동작하지 않습니다.

      defaultToSpeaker

      • 카테고리가 playAndRecord인 경우에만 on / off로 지정할 수 있습니다

      • 그외 카테고리에서는 스피커가 기본으로 출력 포트로 사용됩니다. (port = builtInSpeaker)

      mixWithOthers

      • 앱의 오디오와 다른 앱의 오디오가 함께 믹싱되어 동시에 재생됩니다.

      duckOthers

      • 앱의 오디오가 재생될때 다른 앱의 오디오와 믹싱되지만, 다른 앱의 볼륨이 줄어들게 됩니다.

      interruptSpokenAudioAndMixWithOthers

      • 음성 콘텐츠를 재생 중인 다른 앱의 오디오를 잠시 중단시키면서도, 자신의 오디오를 믹싱하여 재생할 수 있게 하는 옵션입니다.

      • duckOthers와 함께 사용하게 되면 다른 앱에서 미디어 종류의 오디오를 재생할때는 duckOthers의 설정을 따르고, 다른 앱에서 spokenAudio 모드 상태로 출력되고 있을 때만 앱에 인터럽트가 발생합니다.

      overrideMutedMicrophoneInterruption

      • 시스템이 자동으로 마이크를 음소거하는 상황에서도 마이크를 동작하게 합니다. mVoIP나 녹음 상황에서 설정합니다. 하지만 시스템의 인터럽트를 무시하여 예외 현상이 발생할 수 있습니다.


      Notification

      오디오를 점유 중인 상황일 때 점유가 끝난 것을 알고 다시 오디오를 사용하는 시점을 알 수 있을까요?

      다른 앱의 AVAudioSession 점유가 시작되고 끝나는 것을 알기 위해서는 interruptionNotification 알림 구독이 필요합니다.

      private func setupAudioSession() {
          ...
          NotificationCenter.default.publisher(for: AVAudioSession.interruptionNotification, object: session)
              .sink { notification in
              
                  guard let type = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? AVAudioSession.InterruptionType else {
                      return
                  }
                  
                  switch type {
                  case .began:
                      self.stopIOUnit()
                  case .ended:
                      self.startIOUnit()
                  default:
                      break
                  }
                  
          }.store(in: &self.cancelBag)
          ...
      }

      CallKit에서 오디오 사용은 우선순위가 높아 다른 앱으로부터 인터럽트가 발생할 가능성이 적습니다.

      하지만 일부 경우에는 여전히 발생할 수 있으므로 반드시 예외 처리가 필요합니다.

      (예: CallKit 사용 중에 다른 앱에서 CallKit이 호출되는 3자 통화 상황이 발생할 수 있습니다.)

      인터럽트 처리를 통해 다른 앱이 오디오를 점유하면 현재 오디오 사용을 중단해야 합니다.

      다른 곳에서 이미 오디오를 점유했기 때문에 계속 사용하려 하면 오류 상황이 발생할 수 있습니다.

      그리고 다른 앱의 오디오 점유가 끝나면 다시 오디오 유닛 사용을 재개하여 이어 나갑니다.

      mVoIP 엔진에서는 인터럽트 처리가 필수적으로 이루어져야 통화 관련 이슈를 방지할 수 있습니다.

      private func setupAudioSession() {
          ...
          NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification, object: session)
              .sink { notification in
              
                  if let reason = notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? AVAudioSession.RouteChangeReason {
                  
                      switch reason {
                      case .newDeviceAvailable:
                          print("newDeviceAvailable")
                      case .oldDeviceUnavailable:
                          print("oldDeviceUnavailable")
                      case .categoryChange:
                          print("categoryChange")
                      case .override:
                          print("override")
                      case .wakeFromSleep:
                          print("wakeFromSleep")
                      case .noSuitableRouteForCategory:
                          print("noSuitableRouteForCategory")
                      default:
                          print("ReasonUnknown")
                      }
                  }
                  
                  if let description = notification.userInfo?[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription {
                      print("Previous route : \(description)")
                  }
                  
          }.store(in: &self.cancelBag)
          ...
      }

      다음으로 필요한 알림은 routeChangeNotification입니다.

      mVoIP에서는 입출력이 다양하게 변경될 수 있으며, 변경될 때마다 오디오 유닛을 다시 재설정해야 해당 포트로 입출력이 정상적으로 동작하게 됩니다.

      경로 변경이 발생하는 이유는 다음과 같습니다

      • unknown

      • newDeviceAvailable

        • 새로운 장치가 연결되었을 때 발생됩니다.

      • oldDeviceUnavailable

        • 현재 연결된 장치가 끊겼을 때 발생됩니다.

      • categoryChange

        • AudioSession의 카테고리나 옵션 설징이 변경될 때 발생됩니다.

        • overrideOutputAudioPort가 none으로 지정될 경우에도 발생됩니다.

      • override

        • overrideOutputAudioPort가 스피커로 지정될 경우 발생됩니다.

      • wakeFromSleep

        • 대기 모드에서 깨어날 경우 발생됩니다.

      • noSuitableRouteForCategory

        • 카테고리에 맞는 route 포트가 없는 경우 입니다.

      • routeConfigurationChange

        • 오디오 입출력 환경이 변경될 때 발생됩니다.


      경로 변경

      대표적으로 AVAudioSession의 입출력 포트 경로가 변경될 방법은 세 가지가 있습니다.

      • AVRoutePickerView

        일반적으로 AVPlayer나 AVAudioPlayer 등에서 출력 포트를 변경하는 방법은 iOS에서 제공하는 AVRoutePickerView를 사용하는 것입니다.

        이 피커를 표시하면 사용자에게 현재 사용 가능한 포트 목록이 팝업 형태로 나타납니다.

        iOS에서는 팝업을 직접 표시하는 인터페이스를 제공하지 않지만, 간단한 팁을 통해 사용자가 AVRoutePickerView를 표시할 수 있습니다.

        AVRoutePickerView에 노출되는 항목은 미디어 재생, CallKit등 범용적으로 사용되어야 하므로 출력 포트 기준으로 목록이 노출됩니다.

        사용자가 출력 포트를 선택하게 되면 카테고리 설정에 따라 대응하는 입력 포트가 설정됩니다.

      // AVRoutePickerView popup 노출 구현 코드
      for view: UIView in routePickerView.subviews {
          if let button = view as? UIButton {
              button.sendActions(for: .touchUpInside)
              break
          }
      }
      • overrideOutputAudioPort

        CallKit 사용 중 사용자가 헤드셋이나 BluetoothHFP 연결이 되어 있지 않은 상태에서 스피커와 내장 수화기(builtInReceiver) 간 전환을 간단히 하려면

        AVAudioSession의 overrideOutputAudioPort 인터페이스를 사용하는 방법이 있습니다.

        overrideOutputAudioPort에 speaker를 설정하면 사용자의 출력 포트가 스피커로 변경됩니다.

        none으로 설정하면 availableInputs의 첫 번째나 마지막 항목의 입력에 대응하는 입출력 포트로 변경됩니다. (playAndRecord 기준)

      try self.session.overrideOutputAudioPort( .speaker or .none )
      • setPreferredInput

        카테고리 설정이 record나 playAndRecord인 경우 유효한 방법입니다.

        사용자가 직접 AVAudioSession의 availableInputs 포트 중 원하는 입력 포트를 지정하는 방법입니다.

        AVRoutePickerView가 출력 포트를 기준으로 했다면, setPreferredInput은 입력에 맞는 출력을 지정합니다.

        대부분의 경우 입력 포트와 출력 포트는 쌍으로 동작하지만, 일부 상황에서는 사용자가 직접 입력을 지정해야 할 때가 있습니다.

        이 방법을 사용하여 입력 포트를 지정하면 AVRoutePickerView에 표시된 항목과 실제 입출력 표시가 다를 수 있습니다.

        따라서 CallKit에서는 AVRoutePickerView보다 setPreferredInput 방식으로 입력을 직접 제어하는 것이 더 적합합니다.

        하지만 setPreferredInput을 기준으로 사용하게 되면 앱에서 포트 항목을 관리해야 하므로 추가적인 사용자 경험(UX) 구현이 필요합니다.

        참고로, AVRoutePickerView에 노출되는 항목과 CallKit 통화 화면에서 노출되는 경로 항목 사이에 차이가 발생할 수 있습니다.

      try self.session.setPreferredInput(input)


      mediaServicesWereResetNotification

      마지막으로 구독해야 하는 알림은 mediaServicesWereResetNotification입니다.

      이 알림은 오디오를 관리하는 mediaServices가 운영 체제 정책에 의해 재시작될 때 발생합니다.

      이 경우 오디오 상태가 초기화되어 앱의 오디오 설정을 다시 해야 합니다. 자주 발생하는 알림은 아니지만, 일반적인 예외 상황에 대응해야 하는 중요한 예외 처리입니다.

      • 테스트는 “아이폰 > 설정 > 개발자 > 미디어 서비스 재설정”을 실행하면 발생시킬 수 있습니다.

      private func setupAudioSession() {
          ...
          NotificationCenter.default.publisher(for: AVAudioSession.mediaServicesWereResetNotification, object: session)
              .sink { notification in
              
                  Task { @MainActor in
                      
                      self.audioChainIsBeingReconstructed = true
                      
                      try await Task.sleep(nanoseconds: 250000)
                      
                      self.setupAudioChain()
                      self.startIOUnit()
                      
                      self.audioChainIsBeingReconstructed = false
                  }
          }.store(in: &self.cancelBag)
      }

      AVAudioSession의 카테고리와 모드를 재설정하고 오디오유닛을 재시작해야 합니다.

      샘플 코드에서는 mediaServices가 재시작된 후 일정 지연 시간을 두어 초기화가 정상적으로 이루어지기를 기대한 뒤 재시작했습니다.

      이 부분은 mVoIP 엔진과 직접 연관되므로, 실제 적용되는 mVoIP 엔진에서 이러한 처리를 수행하는지 확인이 필요합니다.


      mVoIP 엔진

      샘플 코드에서 mVoIP 엔진 역할을 하는 부분은 다음과 같습니다. AVAudioEngine을 통해 입출력 노드를 설정하고 시작 또는 중지하는 코드입니다. 실제 mVoIP 엔진은 이 샘플 코드와 다르게 작동합니다.

      발신자(caller)의 inputNode로 입력되는 음성을 수신자(callee)의 outputNode로 출력하고, 반대로 수신자의 inputNode 입력을 발신자의 outputNode을 통해 출력하도록 구현됩니다.

      • mVoIP 엔진은 상대방과의 연결 방식, 다양한 환경에서의 통화 품질 유지 등 고도의 기술이 요구됩니다.

      • 과거 샘플 코드를 살펴보면 setupIOUnit의 내용에 차이가 있습니다.

        • AVAudioEngine이 지원되기 이전에는 AudioComponent를 사용하여 유닛을 직접 제어했습니다.

      엔진 초기화

      엔진 초기화에는 AVAudioFormat을 사용하여 포트의 샘플레이트와 채널을 설정합니다.

      샘플 코드에서는 샘플레이트와 채널을 고정으로 지정하였지만, 포트가 변경되면 변경된 포트에 대한 샘플레이트와 채널의 값을 지정하면 됩니다.

      포트 샘플레이트는 AVAudioSession의 sampleRate 지정하고 channels(1 = 모노, 2 = 스테레오) 설정하면 됩니다.

      private func setupIOUnit() {
          let format = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 1)
          let engine = AVAudioEngine()
          do {
              try engine.inputNode.setVoiceProcessingEnabled(true)
              engine.connect(engine.inputNode, to: engine.outputNode, format: format)
              engine.prepare()
          } catch {
              print("setupIOUnit Error : \(error)")
          }
          
          self.engine = engine
      }

      엔진 시작과 종료

      엔진 시작과 종료는 AVAudioEngine의 start와 stop을 사용하면됩니다.

      start는 엔진에 설정한 inputNode와 outputNode의 스트림이 동작하게 됩니다.

      시작 이후 엔진 변경사항이 발생하면 오류가 발생합니다. 엔진을 변경할 때는 반드시 stop을 실행해야 합니다.

      func startIOUnit() {
          
          do {
              try self.engine?.start()
          } catch {
              print("startIOUnit Error : \(error)")
          }
      }
      
      func stopIOUnit() {
          self.engine?.stop()
      }


      AVAudioSession in CXProviderDelegate

      CXProvider에서 오디오 관련 인터페이스를 호출하는 부분은 샘플 코드의 ProviderDelegate.swift를 참고하면 됩니다.

      이 파일은 CXProvider의 delegate를 구현했으며, 실제 CallKit을 사용할 때 많은 도움이 됩니다.

      • CXCallController에 대한 동작은 SpeakerBoxCallManager.swift를 참고하면 됩니다.

      • CallKit에서 AVAudioSession의 활성화/비활성화는 CXProviderDelegate를 통해 알 수 있습니다.

      AVAudioSession 활성, 비활성

      CallKit에서의 오디오 활성화/비활성화는 다른 분야에서 오디오를 사용할 때와는 대조적입니다. 다른 경우에는 사용자가 직접 호출하는 반면, CallKit에서는 시스템이 이를 관리합니다.

      func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
          print("Received", #function)
      
          /*
           Start call audio media, now that the AudioSession is activated,
           after having its priority elevated.
           */
          startAudio()
      }
      
      func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
          print("Received", #function)
      
          /*
           Restart any non-call related audio now that the app's AudioSession is deactivated,
           after having its priority restored to normal.
           */
      }

      이렇게 CXProviderDelegate를 통해서 활성화 여부를 전달받는 것은 CallKit의 특성상 전화에 대한 생명 주기(life cycle)을 CXProvider가 주관하기 때문입니다.

      • 수신자는 CXProviderDelegate의 Answer Action delegate를 호출한 이후에 AVAudioSession이 활성화됩니다.

      • 발신자는 CXProviderDelegate를 통해 Start Action delegate 호출 이후에 AVAudioSession이 활성화됩니다.

      func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
          // Create and configure an instance of SpeakerboxCall to represent the new outgoing call.
          let call = SpeakerboxCall(uuid: action.callUUID, isOutgoing: true)
          call.handle = action.handle.value
      
          /*
           Configure the AudioSession but do not start call audio here.
           Call audio should not be started until the AudioSession is activated by the system,
           after having its priority elevated.
           */
          configureAudioSession()
          ...
      }
      
      func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
          // Retrieve the SpeakerboxCall instance corresponding to the action's call UUID.
          guard let call = callManager.callWithUUID(uuid: action.callUUID) else {
              action.fail()
              return
          }
      
          /*
           Configure the AudioSession but do not start call audio here.
           Call audio should not be started until the AudioSession is activated by the system,
           after having its priority elevated.
           */
          configureAudioSession()
          ...
      }
      
      func configureAudioSession() {
          print("Configuring audio session")
      
          if audioController == nil {
              audioController = AudioController()
          }
      }

      AVAudioSession 카테고리, 모드 설정

      샘플 코드에서도 시작 액션과 응답 액션 시점에 configurateAudioSession을 수행하는 것을 확인할 수 있습니다.

      반드시 이 시점이 아니더라도 이전에 오디오 설정을 수행해도 대부분의 경우 문제가 없습니다.

      하지만 일부 오디오 입출력 장치(예: 카플레이, 에어팟)가 연결된 상태에서 카테고리와 모드를 설정할 때 문제가 발생할 수 있습니다.


      샘플 코드에서 시작과 응답 액션 시점에카테고리, 모드를 설정하는 이유는 앱이 여러 곳에서 AVAudioSession에 접근하여 사용할 수 있기 때문입니다.

      카테고리와 모드를 미리 설정했더라도 CallKit에 의해 오디오가 활성화되기 직전에 설정이 변경되는 오동작을 방지하기 위해, AVAudioSession 설정을 최대한 활성화 직전에 변경하는 것이 권장됩니다.

      앞서 설명한 3자 통화 상황이 발생하면 전화가 전환될 때마다 AVAudioSession의 활성화/비활성화가 호출됩니다.

      3자 통화 상황은 다음과 같은 경우에 발생할 수 있습니다:

      1. 앱에서 CallKit을 사용하여 통화 중일 때 다른 앱에서 CallKit 수신이 발생하면, 사용자에게 대기나 전화 종료를 선택하는 화면이 표시됩니다. (case 1)

      2. 앱에서 CallKit을 사용하여 통화 중일 때 PushKit을 통해 다른 전화가 보고되면 발생합니다.

        이 경우에도 사용자는 새로운 통화를 위해 이전 통화를 대기시키거나 종료하는 선택을 하게 됩니다. (case 2)

      사용자가 대기나 전화 종료를 선택하면, CXProviderDelegate는 먼저 didDeactivateAudioSession을 호출한 후 다시 didActivateAudioSession을 호출합니다.


      마치며

      지금까지 CallKit 샘플 코드를 참고하여 AVAudioSession이 사용되는 부분을 확인했습니다.

      실제 서비스를 개발하다 보면 다양한 이슈에 대응해야 하므로 이 샘플 코드만으로는 부족할 수 있습니다.

      그러나 CallKit에서 오디오를 사용하기 위해서는 이 샘플 코드를 숙지하는 것이 문제 해결에 큰 도움이 됩니다.

      CallKit을 사용하면서 다양한 기능을 구현하고 발생하는 이슈를 해결하기 위해서는 오디오 설정에 대한 깊이 있는 이해가 필요합니다.

      특히 다음과 같은 오디오 설정 요소(카테고리, 옵션, 모드, 포트)에 대한 이해가 중요합니다.

      이러한 설정들을 제대로 이해하고 활용하기 위해서는 관련 기술 문서를 반드시 학습해야 합니다.

      이를 통해 더 안정적이고 효율적인 CallKit 기반의 오디오 기능을 구현할 수 있을 것입니다.

      댓글 0

      DEVOTEE를 활성화 시키면
      지금 작성한 댓글에 AI가 댓글을 달아줍니다.

      rutinfc 님의 최신 블로그

      더보기

      DEVOTEE 추천 블로그

      동영상 기고하기