iOS에서 개발 할 때 음악 파일에서 단순 재생을 하거나 녹음을 하고자 한다면 AVAudioPlayer
나 AVAudioRecorder
를 사용하면 된다. 그러나 재생이나 녹음을 넘어 혼합, 형식 변환, 효과 적용 등 오디오 데이터에 직접 작업을 하길 원한다면 코어오디오 - CoreAudio
를 사용해야 한다. AudioUnit
은 개발자가 사용할 수 있는 가장 로우 레벨의 인터페이스이다. 오디오 변환 유닛, 오디오 출력 유닛 등을 조합해 오디오 그래프를 생성해서 사용할 수 있고 AudioQueue
또한 이와 같은 AudioUnit
을 기반으로 만들어져있다.
Overview
코어오디오를 이용한 음원 재생 튜토리얼에서는 Audio File Service와 AudioQueue
를 이용해 AAC파일을 재생을 할 것이다. 간략하게 다음과 같은 순서를 따라간다
- 로컬 경로에 존재하는 AAC파일 에서부터
AudioFileID
을 생성한다. AudioFileID
에서 오디오 데이터 포맷 (AudioStreamBasicDescription
)을 추출한다.AudioQueueRef
를 생성한다. 이 때 2번에서 구한 오디오 데이터 포맷을 적절히 세팅해 준다. 또한 큐에 있는 버퍼를 비웠을 때 불릴 callback 함수를 설정해 준다.- 버퍼를 생성해서 오디오 데이터를 채워준다음
AudioQueueRef
에 enqueue 해준다. - Callback 함수에서는 빈 버퍼를 인자로 받아서 오디오 데이터를
AudioQueueRef
에 enqueue 해 주도록 구현한다. AudioQueueStart
를 부르면 재생이 시작되고 버퍼가 비워지면 5번의 callback 함수가 불린다.
Step 0 사용자 구조체 정의
CoreAudio를 이용해서 재생이나 녹음을 할 때 callback 함수를 많이 사용하게 되는데 이 때 인자로 받을 사용자 정의 구조체를 정의해 주어야 한다. callback함수가 불렸을 때 인자로 다 쓴 buffer도 함께 들어오는데 여기에 데이터를 넣을 때 필요한 정보들을 포함해야 한다. 언뜻 이해가 안 갈 수도 있지만 callback함수 구현하는 쪽에서 다시 보게 될 것이니 하나씩 개념만 짚고 넘어가자.
static const int kNumberBuffers = 3; // 1 struct MyPlayer { AudioStreamBasicDescription mDataFormat; // 2 AudioQueueRef mQueue; // 3 AudioQueueBufferRef mBuffers[kNumberBuffers]; // 4 AudioFileID mAudioFile; // 5 UInt32 bufferByteSize; // 6 SInt64 mCurrentPacket; // 7 UInt32 mNumPacketsToRead; // 8 AudioStreamPacketDescription *mPacketDescs; // 9 bool mIsRunning; // 10 };
- 오디오 큐 버퍼는 3개로 정의한다. (재생용/대기용/채우는용)
AudioStreamBasicDescription
는 오디오 스트림의 광범위한 특성를 정의하는 구조체이다.- 재생 오디오 큐
- 오디오 큐 버퍼 포인트의 리스트
- 오디오 파일 객체
- 오디오 큐 버퍼 하나의 사이즈
- 다음 재생할 패킷의 위치
- playback callback 이 불렸을 때 한번에 읽을 packet 의 갯수
- VBR 오디오인 경우 필요한 packet 정보
- 현재 재생 오디오큐가 재생 중인지
Step 1 AudioFile 열기
코어 오디오에서의 Audio File Service는 디스크나 메모리로 부터 audio file을 열고, 그것이 포함한 오디오 데이터 형식을 얻거나 설정하고, 파일에 대해 읽기나 쓰기를 할 수 있게 한다. AudioFileOpenURL()
함수는 이미존재하는 audio file을 여는 함수이다.
// 함수 정의 AudioFileOpenURL ( CFURLRef inFileRef, AudioFilePermissions inPermissions, AudioFileTypeID inFileTypeHint, AudioFileID outAudioFile); //output pointer
아래 함수까지 작성했으면 구조체 mPlayer의 playbackFile 이라는 필드에 AudioFileID
의 포인터가 할당 되어 있을 것이다.
MyPlayer mPlayer ={0}; // 구조체 초기화 CFStringRef aacPath = // 실제 AAC파일의 경로 //; CFURLRef myFileUrl = CFURLCreateWithFileSystemPath(kCFAllocatorDefault, path, kCFURLPOSIXPathStyle, false); AudioFileOpenURL(myFileUrl, kAudioFileReadPermission, 0, &mPlayer.mAudioFile), // output pointer CFRelease(myFileUrl);
Step2 AudioDataFormat 불러오기
이제 step1에서 오픈한 AudioFileID
로부터 AudioFileGetProperty()
함수를 이용해서 정보를 불러올 차례이다.
//함수정의 AudioFileGetProperty( AudioFileID inAudioFile, // 정보를 가져올 AudioFile AudioFilePropertyID inPropertyID, // 어떤정보를 가져올지 constant UInt32* ioDataSize, void* outPropertyData); // output pointer
두 번째 인자인 AudioFilePropertyID
의 리스트는 애플문서에 정의되어 있다. 여기에서는 AudioStreamBasicDescription
정보를 가져오기 위해서 kAudioFilePropertyDataFormat
상수를 사용했지만 오디오의 AlbumArtwork, PacketCount, Duration 등의 정보도 가져올 수 있으니 확인해보자.
UInt32 dataFormatSize = sizeof(mPlayer.mDataFormat); AudioFileGetProperty(mPlayer.mAudioFile, //데이터를 가져올 소스 kAudioFilePropertyDataFormat, // 가져올 데이터 종류 &dataFormatSize, &mPlayer.mDataFormat);
여기서는 가져올 데이터가AudioStreamBasicDescription
타입인 것을 알기 때문에 사이즈가 고정되어 있으나 타입에 따라서 사이즈를 알지 못할 때도 있다. 이런 경우에는 AudioFileGetPropertyInfo()
함수를 이용해서 사이즈를 가져온다.
위에 등장한 AudioStreamBasicDescription(ASDB)
가 무엇인지 알아보자.
ASDB는 코어오디오에서 사용하는 오디오 스트림의 광범위한 특성를 정의하는 구조체이다. 즉 채널이 몇개인지, 형식은 무엇인지, bitrate 는 몇인지 등을 포함한다.오디오 데이터 자체는 포함하지 않고 데이터의 정보만 저장한다.
struct AudioStreamBasicDescription { Float64 mSampleRate; AudioFormatID mFormatID; AudioFormatFlags mFormatFlags; UInt32 mBytesPerPacket; UInt32 mFramesPerPacket; UInt32 mBytesPerFrame; UInt32 mChannelsPerFrame; UInt32 mBitsPerChannel; UInt32 mReserved; };
LPCM 포맷이나 모든 패킷이 정보가 동일한 constant bit rate (CBR) 포맷은 ASDB만으로도 충분하지만 패킷마다 bitrate가 다를 수 있는 variable bit rate (VBR) 포맷은 각 패킷의 정보를 담고 있는 AudioStreamPacketDescription
가 추가적으로 필요하다. VBR포맷의 ASBD를 프린트 해 보면 패킷마다 값이 다르기 때문에 mBytesPerPacket
와 mFramesPerPacket
의 값은 0이다.
struct AudioStreamPacketDescription { SInt64 mStartOffset; UInt32 mVariableFramesInPacket; // 한 패킷의 프레임 수 UInt32 mDataByteSize; // 패킷의 실제 크기 };
Step 3 AudioQueue생성하기
코어오디오 에서의 AudioQueue
는 오디오 하드웨어의 일부와 통하는 간단한 인터페이스이다. 하드웨어의 일부라 함은 보통 스피커나 마이크를 말한다. AudioQueue
는 버퍼 큐와 연결되어 있고 버퍼는 실제 오디오 데이터를 가지고 있는 메모리 블럭이다. 재생 큐는 데이터로 채워진 버퍼를 받아서 스피커 하드웨어에 전달 한 뒤 빈 버퍼를 콜백 함수에 전달한다. 콜백 함수에서 파일로부터 오디오 데이터를 읽어 버퍼에 담아준 뒤 오디오 큐에 전달한다.
//AudioQueue 생성 함수 정의 OSStatus AudioQueueNewOutput( AudioStreamBasicDescription *inFormat, AudioQueueOutputCallback inCallbackProc, void *inUserData, CFRunLoopRef inCallbackRunLoop, CFStringRef inCallbackRunLoopMode, UInt32 inFlags, AudioQueueRef *outAQ); // output pointer
아래처럼 코드를 작성해 준다. 아직 콜백함수는 작성하지 않았다. inUserData
위치에 mPlayer
구조체를 넣어주면 callback 함수가 불릴 때 인자로 같이 넘어오게 된다.
AudioQueueNewOutput(&mPlayer.mDataFormat, MyAQOutputCallback, // 콜백함수 &mPlayer, // 사용자 정의 구조체 NULL, NULL, 0, &mPlayer.mQueue), // output audioQueue
Step 4 Buffer 사이즈 계산하기
코어오디오에서 오디오 데이터를 오디오 큐에 전달하기 위해서는 AudioQueueBuffer
라는 객체를 사용한다. 이 버퍼를 생성하기 전에 재생할 오디오 데이터를 분석해서 어떤 사이즈의 버퍼를 사용할 지, 한 번에 몇개의 packet을 읽을지 결정해야 한다.
다음 함수는 사용할 버퍼의 사이즈를 outBufferSize
에 저장하고, 읽을 패킷의 갯수를 outNumberPacketsToRead
에 저장된다.
void DeriveBufferSize ( AudioStreamBasicDescription &ASBDesc, UInt32 maxPacketSize, Float64 seconds, UInt32 *outBufferSize, UInt32 *outNumPacketsToRead ) { static const int maxBufferSize = 0x50000; // 320KB static const int minBufferSize = 0x4000; // 16KB if (ASBDesc.mFramesPerPacket != 0) { // 1 Float64 numPacketsForTime = ASBDesc.mSampleRate / ASBDesc.mFramesPerPacket * seconds; *outBufferSize = numPacketsForTime * maxPacketSize; } else { // 2 *outBufferSize = maxBufferSize > maxPacketSize ? maxBufferSize : maxPacketSize; } if (*outBufferSize > maxBufferSize && // 3 *outBufferSize > maxPacketSize ) *outBufferSize = maxBufferSize; else { // 4 if (*outBufferSize < minBufferSize) *outBufferSize = minBufferSize; } *outNumPacketsToRead = *outBufferSize / maxPacketSize; // 5 }
ASBDesc.mFramesPerPacket != 0
이라면 ASDB에 패킷당 프레임 갯수가 전체 오디오 데이터를 통틀어 일정하다는 뜻이다. 그렇다면 위와 같은 수식으로 주어진 시간 (=seconds
)에 처리할 수 있는 패킷의 갯수 (=numPacketsForTime
) 를 구할 수 있고 이를 통해 주어진 시간에 필요한 버퍼의 크기(=outBufferSize
)를 계산할 수 있다.- 패킷당 프레임 갯수가 다른 경우라면
maxBufferSize
와maxPacketSize
(AudioFileGetProperty
함수로 구할 수 있음) 를 비교해서 더 큰 값으로 설정해 준다. - 얻은 값이 너무 크다면
maxBufferSize
에 맞춰준다 - 얻은 값이 너무 작다면
minBufferSize
에 맞춰준다 - 버퍼의 크기와 최대 패킷 크기를 안다면 콜백 한번에 읽을 수 있는 패킷 갯수를 계산한다.
그럼 이제 위 함수를 이용해서 버퍼 사이즈와 읽을 패킷 갯수를 실제로 계산해보자.
UInt32 maxPacketSize; UInt32 propertySize = sizeof (maxPacketSize); AudioFileGetProperty ( // 1 mPlayer.mAudioFile, kAudioFilePropertyPacketSizeUpperBound, &propertySize, &maxPacketSize ); DeriveBufferSize ( // 2 mPlayer.mDataFormat, maxPacketSize, 0.5, &mPlayer.bufferByteSize, &mPlayer.mNumPacketsToRead );
AudioFileGetProperty
함수를 이용해서 maximum packet size를 구한다.DeriveBufferSize
함수를 이용해서bufferByteSize
와mNumberPacketToRead
를 구한다. 여기서 주어진 시간은 0.5초 이다.
Step 5 PacketDescription 메모리 설정
아직 세팅할 데이터가 더 남았다 (….)
위에도 말했듯이 모든 Packet 사이즈가 동일한 CBR은 ASBD만으로도 충분한 정보가 되지만 VBR같은 경우는 각 패킷 마다 추가적인 정보를 들고 있어야 한다. 이를 위해 오디오 데이터가 CBR 인지 VBR인지 구분해서 사용자 구조체에 메모리를 할당해 주자.
bool isFormatVBR = ( // 1 mPlayer.mDataFormat.mBytesPerPacket == 0 || mPlayer.mDataFormat.mFramesPerPacket == 0 ); if (isFormatVBR) { // 2 mPlayer.mPacketDescs = (AudioStreamPacketDescription*) malloc ( mPlayer.mNumPacketsToRead * sizeof (AudioStreamPacketDescription) ); } else { // 3 mPlayer.mPacketDescs = NULL; }
- 해당 오디오 데이터가 VBR인지 구분하는 방법은 간단하다. 위에서 구한
ASBD
에mBytesPerPacket
이나mFramesPerPacket
의 값이 0인지 확인하면 된다. - 만약 VBR format 이라면
AudioStreamPacketDescription
의 사이즈 x 한번에 읽을 패킷 갯수 (=mNumPacketsToRead
) 만큼 메모리를 할당해 준다. 이 정보는 오디오 큐 콜백 함수에서 오디오 데이터를 읽어들일 때 각각의 패킷을 분석할 때 쓰인다. - CBR format은
AudioStreamPacketDescription
가 필요하지 않으므로 메모리를 할당하지 않는다.
Step 6 Magic Cookie 설정
아직도 설정이 끝나지 않았다. 오디오에서 magic cookie 란 MPEG4나 AAC 와 같은 압축 오디오 데이터 형식에서 사용하는 오디오 메타 데이터이다. 세부적인 내용은 몰라도 되며 오디오 데이터에서 magic cookie를 꺼내서 오디오 큐에 세팅해 줘야 한다.
UInt32 cookieSize = sizeof (UInt32); OSStatus getPropertyResult = // 1 AudioFileGetPropertyInfo ( aqData.mAudioFile, kAudioFilePropertyMagicCookieData, &cookieSize, NULL ); if (getPropertyResult == noErr && cookieSize) { // 2 char* magicCookie = (char *) malloc (cookieSize); AudioFileGetProperty ( aqData.mAudioFile, kAudioFilePropertyMagicCookieData, &cookieSize, magicCookie ); AudioQueueSetProperty ( // 3 aqData.mQueue, kAudioQueueProperty_MagicCookie, magicCookie, cookieSize ); free (magicCookie); }
- Magic cookie 데이터의 크기를 잘 모르기 때문에
AudioFileGetPropertyInfo
함수를 이용해서 오디오 데이터에 실제로 magic cookie가 존재하는지, 존재 한다면 사이즈를 구해서cookieSize
에 할당해 준다. AudioFileGetPropertyInfo
함수에 대한 결과가 error가 아니고 (즉 magic cookie가 존재하고) 사이즈를 구했다면AudioFileGetProperty
함수를 이용해서 실제로 magic cookie 정보를 불러온다.- 오디오 데이터에서 불러온 magic cookie 정보를
AudioQueue
에게 전달한다.
Step 7 AudioQueueOutputCallback 함수 구현
지루한 설정은 끝났고, 이제 CoreAudio에서 제일 중요한 부분을 구현할 차례이다. 위의 Step 3에서 AudioQueue를 새로 생성할 때 연결 해 주었던 AudioQueueOutputCallback
함수를 떠올려보자. 이 함수는 AudioQueue가 버퍼에 담긴 오디오 데이터를 재생하는데 모두 소진하고 이 버퍼가 다시 사용될 수 있을 때 불린다. 즉, 이 콜백 함수가 불렸을 때 같이 전달된 버퍼에 오디오 데이터를 밀어 넣는 작업을 수행하여야
한다.
static void AudioQueueOutputCallback ( void *aqData, // AudioQueueNewOutput 에서 전달한 사용자 데이터 AudioQueueRef inAQ, // 콜백을 부른 AudioQueue AudioQueueBufferRef inBuffer // 채워야 할 buffer )
콜백 함수에서 해야 할 일은 3가지이다.
1. 오디오 버퍼에 오디오 데이터 넣기
아래는 AudioFile에서 데이터를 읽어서 오디오 버퍼로 밀어넣는 작업을 하게 해주는 AudioFileReadPackets
함수의 정의이다.
//함수정의 AudioFileReadPackets( AudioFileID inAudioFile, // 데이터를 읽어들일 AudioFile Boolean inUseCache, // cache를 할지말지 UInt32 *outNumBytes, // 실제로 읽은 데이터의 길이 (output) AudioStreamPacketDescription *aspd, // ASPD array의 포인터, CBR인 경우 NULL SInt64 inStartingPacket, // 읽을 패킷의 위치 UInt32 *ioNumPackets, // input: 읽을 packet의 갯수 // output : 실제 읽은 packet 갯수 void *outBuffer // 채울 오디오 버퍼 );
위 함수를 이용해서 오디오 버퍼를 채워주는 과정을 구현 하면 아래와 같다.
MYPlayer *mPlayer = (MYPlayer *) aqData; // 1 if (mPlayer->mIsRunning == false) return; UInt32 numBytesReadFromFile; // 2 UInt32 numPackets = mPlayer->mNumPacketsToRead; // 3 AudioFileReadPackets ( mPlayer->mAudioFile, false, &numBytesReadFromFile, mPlayer->mPacketDescs, mPlayer->mCurrentPacket, &numPackets, inBuffer->mAudioData )
- Callback에서 넘어온 aqData를 직접 구현한 사용자 구조체로 캐스팅
- 실제로 읽은 데이터의 길이를 저장하기 위한 변수
- 몇개의 패킷을 읽어야 하는지 (Step 4에서 구한 값)
위 스텝이 끝나고 나면 numPackets 에는 실제로 읽은 packet의 갯수가 저장되어 있다.
2. 오디오 큐에 오디오 버퍼 Enqueue 하기
버퍼가 준비가 되었다면 AudioQueueEnqueueBuffer
함수를 이용해 해당 버퍼를 오디오 큐에 밀어넣어 준다.
//함수정의 AudioQueueEnqueueBuffer( AudioQueueRef inAQ, // 버퍼를 넣을 오디오 큐 AudioQueueBufferRef inBuffer, // 넣을 버퍼 UInt32 inNumPacketDescs, // ASPD 의 갯수, CBR == 0 const AudioStreamPacketDescription *inPacketDescs // ASPD array의 포인터 );
inBuffer->mAudioDataByteSize = numBytesReadFromFile; AudioQueueEnqueueBuffer ( mPlayer->mQueue, inBuffer, (mPlayer->mPacketDescs ? numPackets : 0), mPlayer->mPacketDescs ); mPlayer->mCurrentPacket += numPackets; // 현재 읽고 있는 packet의 위치
3. 음악이 끝난 경우 오디오 큐 정지시키기
더이상 읽어들일 오디오 데이터가 없을 때 오디오 큐를 정지시킨다.
AudioQueueStop ( mPlayer->mQueue, // 멈출 audioQueue false // immediately 멈출지 enqeue된 버퍼까지는 모두 재생하고 멈출 지 ); mPlayer->mIsRunning = false; // playback이 끝났다는 표시
Step 8 Allocate AudioQueueBuffers
돌이켜보면 Step 4 에서 오디오 버퍼의 사이즈만 계산하고 실제로 오디오 큐 버퍼를 생성한 적은 없다. 이제 모든 준비가 거의 끝나가니 오디오 AudioQueueAllocateBuffer
함수를 이용해 큐 버퍼를 생성하고 세팅해 보자. kNumberBuffers은 Step 0에서 3개로 세팅하였다. 사실 버퍼 갯수는 더 많아도 상관없지만 보편적으로 3개 정도로 맞춘다. (스트리밍의 경우 네크워크의 상태에 따라서 데이터를 많이 받아 놓아야 할 수도 있기 때문에 더 많은 것이 일반적이지만 로컬 파일 재생의 경우는 3개로도 충분하다.)
mPlayer.mCurrentPacket = 0; for (int i = 0; i < kNumberBuffers; ++i) { AudioQueueAllocateBuffer ( mPlayer.mQueue, // 버퍼를 할당할 오디오 큐 mPlayer.bufferByteSize, // 버퍼의 사이즈 &mPlayer.mBuffers[i] // 새로 생긴 버퍼의 포인터 ); MyAQOutputCallback ( &mPlayer, mPlayer.mQueue, mPlayer.mBuffers[i] ); }
AudioQueueAllocateBuffer
함수를 이용해서 Buffer를 생성한 뒤 MyAQOutputCallback
함수를 불러주었다. 이렇게 하는 이유는 AudioQueue 가 실제로 start 하기 전에 버퍼에 데이터를 미리 채워서 enqueue 해 놓으려는 의도이다.
Step 9 Start Playing
자, 이제 코어오디오로 음원을 재생할 모든 준비는 끝났다!! 바로 재생하려면 두번째 인자에 NULL
을 넣고 시간차를 두고 재생 하고자 하면 AudioTimeStamp
객체를 만들어 넣어주면 된다.
aqData.mIsRunning = true; AudioQueueStart ( aqData.mQueue, NULL );
Step 10 Finish Playing
파일 재생을 마쳤을 때는 꼭 AudioFileID
와 AudioQueue
를 정리해 줘야 한다. AudioQueueDispose
함수의 두번째 인자는 dispose를 바로 진행할 지, enqueue 된 버퍼를 전부 소진 한 후 dispose 할지 결정한다.
AudioQueueDispose ( aqData.mQueue, true ); AudioFileClose (aqData.mAudioFile); free (aqData.mPacketDescs);