Core Audio의 Audio Queue 실행 중 오디오 데이터 저장 시 발생하는 문제 해결

이재웅 (Curry)·2023년 12월 14일
0
post-thumbnail

문제상황

  1. iOS 버전이 15 → 16 → 17으로 올라갈 때 마다 BLE를 통한 심폐음 측정 앱에서 매번 ‘심/폐음 측정 중 → 녹음시작’ 과정에 EXC_BAD_ACCESS 에러가 나타남
심/폐음 측정 중녹음 중

(이미지와 같이 '심/폐음 측정 중' 에서 녹음버튼을 눌러
이미 측정된 오디오 그래프, 데이터를 초기화 한 후 '녹음 중' 상태로 변경함)

  1. 문제 발생위치 : 기존의 ‘심/폐음 측정 중 → 녹음시작’ 로직의 문제

(1) 처음 측정을 시작할 때, 디바이스에서 보낸 오디오 패킷을 받아 전달해주는 DataSource 객체와 CoreAudio의 AudioQueue를 통해 패킷을 받아 실제 오디오를 출력해주는 PCMPlayer 객체를 초기화 및 출력 시작

class MeasureViewController: UIViewController {
	var dataSource: DataSource?
	var pcmPlayer: PCMPlayer?
		
	// 측정시작 메소드
	func startMeasurement() {
		// 객체 초기화 및 시작 
		dataSource = DataSource()
		pcmPlayer = PCMPlayer()
		dataSource?.delegate = self
		dataSource?.start()
		pcmPlayer?.start()
	}
}

extension MeasureViewController: DataSourceDelegate {
	func dataSourceAudioCallback(audioData: Data) {
		// 디바이스로부터 전달받은 오디오 패킷 데이터를 PCMPlayer로 전달
		pcmPlayer.playData(audioData)
	}
}

(2) 녹음을 하기 위해선, 현재까지 측정된 패킷이 아닌 녹음이 시작된 이후에 측정될 오디오 패킷이 필요하기 때문에, DataSource 객체와 PCMPlayer 객체를 다시 초기화해줘서 시작함.

class MeasureViewController: UIViewController {
	var dataSource: DataSource?
	var pcmPlayer: PCMPlayer?
		
	func startRecord() {
		// 현재 전달받는 오디오 데이터 전송 중지, 오디오 출력 중지
		dataSource?.stop()
		pcmPlayer?.stop()
		// 객체 초기화 및 재시작 
		dataSource = DataSource()
		pcmPlayer = PCMPlayer()
		dataSource?.delegate = self
        dataSource?.start()
		pcmPlayer?.start()
	}
}

(3) 이 과정에서, PCMPlayer가 구현되어 있는 swift 파일의 audioQueueOutputCallback(clientData:AQ:buffer:) 함수 내에서 EXC_BAD_ACCESS 에러가 발생함

💡 문제발견과정

1. EXC_BAD_ACCESS 에러로 보아, PCMPlayer를 Unmanaged 객체로 참조를 하려 했는데 이미 메모리에 해제되어 있는 상태여서 오류가 발생했다 판단
2. PCMPlayer 객체가 메모리에서 해제될 때 → 새로운 인스턴스로 초기화시켜 할당함으로, 이 과정에서 문제가 발생했을 것이라 예측
3. startRecord() 메소드에서, PCMPlayer 초기화 직전과 직후로 Breakpoint를 사용해 예상한 문제 확인

원인 파악

  1. Unmanaged, fromOpaque

Unmanaged
C API에 의해 리턴되는 객체의 메모리 라이프사이클을 관리하기 위한 구조체.

fromOpaque(_:)
Unretained 객체를 void 포인터(UnsafeMutableRawPointer)로 상호변환 시켜주는 메소드

takeUnretainedValue()
Unretained 객체를 사용하기 위해 참조수를 내리지 않고 리턴하는 함수

fromOpaque(_:)에서 에러가 발생한 것으로 보아 PCMPlayer에 대한 포인터를 반환하려 했지만 메모리에서 해제되어 있어 EXC_BAD_ACCESS가 나타난 것


  1. Audio Queue Service Programming

Audio Queue Services
Core Audio의 일부인 Audio Toolbox 프레임워크의 C 프로그래밍 인터페이스

Audio Queue
iOS, Mac OS X에서 오디오 녹음 또는 실행할 수 있도록 하는 소프트웨어 객체

Audio Queue 재생과정
1. 외부의 오디오 입력소스(패킷)를 Audio Queue의 callback 함수를 통해 Audio Queue Buffer로 전달함
2. Audio Queue는 내부에 있는 Audio Queue Buffer에 오디오 패킷을 채움
3. Audio Queue Buffer가 가득차게 되면 Audio Queue 내부의 Buffer Queue로 이동하여 Buffer 내부에 있는 패킷을 비우며 재생하게 됨
4. Buffer를 모두 비우게 되면 callback 함수에게 해당 Buffer를 보내 다시 오디오 패킷을 채우도록 하고, Buffer Queue는 enqueue 되었던 다음 Buffer를 실행
5. 1 ~ 4 반복
(PCMPlayer에서 구현한 callback 함수 == EXC_BAD_ACCESS 에러가 발생한 audioQueueOutputCallback(clientData:AQ:buffer:) 함수)

→ callback 함수가 오디오 패킷을 Audio Queue로 전달하는 도중 PCMPlayer가 메모리에서 해제되어 있어 EXC_BAD_ACCESS 발생
(PCMPlayer : 커스텀으로 구현한 Audio Queue 관리 객체)

pcmPlayer?.stop()를 통해 Audio Queue를 멈췄는데 왜 callback함수가 실행되어서 메모리 해제가 발생할까?

class PCMPlayer: AnyObject {
	// stop() 메소드 구현방식
	func stop() {
		AudioQueueStop(audioQueueRef!, false)
		...
	}
}

AudioQueueStop(_:_:)

func AudioQueueStop(
    _ inAQ: AudioQueueRef,
    _ inImmediate: Bool
) -> OSStatus

inAQ : 정지시킬 Audio Queue
inImmediate : true일 경우, Audio Queue가 즉시 정지(동기적으로). false일 경우, 기능은 즉시 반환되지만, 대기 중인 버퍼가 재생될 때 까지 Audio Queue는 멈추지 않음(즉, AudioQueueStop(_:_:)은 비동기적으로 발생). Audio Queue에서 사용하는 callback은 Audio Queue가 실제로 멈출 때까지 필요에 따라 호출됨.

→ 기존에 구현된 stop() 메소드는 AudioQueueStop을 비동기로 실행했음. 그렇기 때문에 기존의 startRecord() 메소드에서 pcmPlayer?.stop() 를 통해 AudioQueueStop(_:_:)을 Audio Queue 쓰레드(com.apple.coreaudio.AQClient 쓰레드)로 보내 비동기로 실행시키고, Audio Queue가 완전히 멈췄는지 확정적이지 않은 상태에서 바로 다음줄에 PCMPlayer를 초기화(메인쓰레드)시키는 것이였음.

→ 따라서 작업을 요청한 순서대로 Audio Queue 쓰레드에서 Audio Queue를 멈추는것이 먼저 실행되고 메인쓰레드에서 PCMPlayer를 초기화 시키면 문제없이 실행되는 것이고, 메인쓰레드가 먼저 실행된다면 아직 실행중인 Audio Queue의 callback 함수가 메모리가 해제된 PCMPlayer의 포인터를 참조하다 EXC_BAD_ACCESS 에러가 나타나게 된 것이다.

→ 이렇게 비동기적으로 작업을 처리하기 때문에 iOS 버전, 아이폰 기기 성능에 따라 작업처리 순서가 달라지면서 문제가 발생했던 것이다.

문제해결

함수 실행시간 : startRecord() 실행시간 기준

시도 1 - PCMPlayer stop()과 초기화 사이에 메인 쓰레드 sleep

녹음실행 시, PCMPlayerstop() 메소드와 인스턴스 초기화 사이에 의도적으로 메인 쓰레드를 1초간 멈추는 것.

class MeasureViewController: UIViewController {
	var dataSource: DataSource?
	var pcmPlayer: PCMPlayer?
		
	func startRecord() {
		// 현재 전달받는 오디오 데이터 전송 중지, 오디오 출력 중지
		dataSource?.stop()
        pcmPlayer?.stop()
        // 쓰레드를 1초간 멈추기
		sleep(1)
		// 객체 초기화 및 재시작 
		dataSource = DataSource()
		pcmPlayer = PCMPlayer()
		dataSource?.delegate = self
		dataSource?.start()
		pcmPlayer?.start()
	}
}

결과

어느 기기에서든 EXC_BAD_ACCESS가 나타나지 않고 정상 실행.

단, 임의로 1초 이내에 Audio Queue가 정지될 것으로 예측하여 짠 코드이기 때문에 완벽하게 동기화되지 않음. 혹여나 iOS 버전이 변하거나 기기 상태에 따라 Audio Queue가 정지되는 시간이 1초 이상 걸릴 경우 다시 크래쉬가 발생할 수 있다.

‘측정 중 → 녹음시작‘ 실행시간 : 평균 2.04초

시도 2 - 동기로 AudioQueueStop 실행

PCMPlayerstop() 메소드에서 AudioQueueStop(_:_:)을 비동기가 아닌 동기로 실행.

class PCMPlayer: AnyObject {
	// stop() 메소드 구현방식
	func stop() {
		// false에서 true로 변경 -> 비동기에서 동기로 실행
		AudioQueueStop(audioQueueRef!, true)
		...
	}
}

결과

AudioQueueStop(_:_:)을 동기로 실행할 경우, 메인 쓰레드에서 Audio Queue를 정지한 후 다시 시작하게 됨. 따라서 EXC_BAD_ACCESS는 어느 상황에서도 발생하지 않음. ‘측정 중 → 녹음시작’ 과정의 문제는 사라졌는데, 반대로 ‘녹음 중 → 중지’ 과정에서 pcmPlayer?.stop() 을 실행시키니 동작시간이 약 14초나 발생하게 되었다.

‘측정 중 → 녹음시작’ 과정과 ‘녹음 중 → 중지’ 과정 모두 동기로 Audio Queue를 정지시키는데 실행시간이 다른 이유는 정확히 확인하지 못했지만 동기로 정지를 시킬 때, 모든 작업을 메인 쓰레드에서 실행하다 보니 시간이 오래걸리는 현상이 발생할 수 있다고 판단되었다.
(실제로 ‘측정 중 → 녹음시작’ 과정에서도 실행시간이 10초 이상 나타날 때가 있었음)

따라서 상황에 따라 ‘측정 중 → 녹음시작’ 과정에서도 실행시간이 매우 증가할 수 있다고 판단되어 서비스에 적용할 수 있는 해결법은 아니라고 판단했다.

‘측정 중 → 녹음시작‘ 실행시간 : 평균 1.07초
‘녹음 중 → 중지’ 실행시간 : 평균 10초 이상

시도 3 - Audio Queue를 멈추지 않고 녹음하기 위한 오디오 패킷만 리셋하기 (최종)

Audio Queue 관련 코드에서 크래시가 발생했다 보니 해당 부분을 통해 문제를 해결하기 위해 Audio Queue 관련 코드 내에서만 여러 시도를 해보았다. 하지만 오디오를 출력하는 PCMPlayer 객체와 오디오 패킷을 보내주고 저장하는 DataSource 객체를 stop() 시키고 새로운 인스턴스를 할당하는 이유가 오디오를 저장하기 위함인데, 새로 초기화 했던 이유는 무엇일까?

오디오를 녹음하고 저장하기 위해 WAV 파일 형태로 만들 때는 [Int16] 타입으로 되어있는 오디오 패킷 데이터를 인코딩해 파일로 내보낸다.

현재 오디오 패킷이 전달되는 방식

과정 1 : 블루투스로 연결된 디바이스 → Core Bluetooth 관련 객체 → DataSource
과정 2 - (1) : DataSourceDataSource와 연결된 내부 SDK에서 [Int16] 타입의 변수에 오디오 패킷 저장
과정 2 - (2) : DataSource → delegate를 통해 PCMPlayer로 오디오 패킷 전달 → 오디오 실행

녹음이 완료되어 파일로 만들땐 내부 SDK에 저장되어 있는 오디오 패킷을 인코딩 해준다는 것. 그렇기 때문에 녹음에만 사용될 오디오 패킷을 새롭게 받기 위해 DataSource 를 초기화 시키는 것이였다.

→ 그렇다면 오디오를 출력하기 위해 PCMPlayer의 Audio Queue callback 함수로 전달되는 오디오 패킷과 저장하기 위한 오디오 패킷은 DataSource에 연결된 SDK에서 따로 관리되고 있는데, PCMPlayerDataSource를 초기화 시키지 않고 오디오 패킷 저장하는 부분만 리셋하면 되지 않을까?

class MeasureViewController: UIViewController {
		var dataSource: DataSource?
		var pcmPlayer: PCMPlayer?
		
		func startRecord() {
					// 현재 전달받는 오디오 데이터 전송 중지, 오디오 출력 중지
					// AudioQueue를 중지시키지 않고 AudioQueue에 오디오 패킷 전달만 중지함.
                    //수정 : dataSource를 정지하지 않아도 됨
					//dataSource?.stop()
					
					// DataSource에 연결된 SDK의 오디오 패킷을 리셋시키는 부분
					dataSource?.resetPacketStorage()
                    // DataSource에 연결된 디바이스에 새롭게 오디오 패킷을 요청함 (오디오 외에 기타정보도 함께 전달받아 리셋하기 위해)
                    dataSource?.requestPayload(parameter)
		}
}

결과

어떤 환경에서도 ‘측정 중 → 녹음’ 과정이 동일하게 유지되면서, AudioQueue 작업시간에 영향을 받지 않고 오디오 패킷 저장만 가능하도록 구현 성공.

SDK의 오디오 패킷을 저장하는 변수는 Mutex lock으로 동기화 문제를 방지했기 때문에 메인쓰레드에서 SDK의 오디오 패킷을 리셋시킨다 하더라도 문제가 발생할 가능성은 거의 없다.

또한 불필요한 AudioQueue 관련 작업이 사라졌기 때문에 해당 로직의 성능도 좀 더 향상되었다.

‘측정 중 → 녹음시작‘ 실행시간 : 평균 0.0003초(수정)

최종

해당 문제가 iOS 17로 업데이트 된 후, 메인쓰레드를 멈추는 방식(sleep)을 임시방편으로 업데이트 한 다음 꽤 오랜시간이 지나 해결하게 되었다.

이 문제를 최종적으로 해결했을 당시에 ‘Core Audio를 열심히 공부했건만 정작 다른곳이 문제였네?’ 라는 생각도 들었지만 Core Audio, Audio Queue Service Programming을 포함해 공식문서를 자세히 공부하지 않았다면 근본적으로 문제를 해결할 방법을 판단하지 못했을 것이다.

앞으로 다른 문제가 발생하더라도 정확한 문제를 파악할 수 있도록 공식문서를 통해 동작 방식과 개념을 잘 이해하는게 중요하다고 느낀다.


참고

profile
iOS 개발자 이재웅입니다.

0개의 댓글