[튜토리얼] CoreAudio를 이용한 음원 재생

iOS에서 개발 할 때 음악 파일에서 단순 재생을 하거나 녹음을 하고자 한다면 AVAudioPlayerAVAudioRecorder를 사용하면 된다. 그러나 재생이나 녹음을 넘어 혼합, 형식 변환, 효과 적용 등 오디오 데이터에 직접 작업을 하길 원한다면 코어오디오 - CoreAudio를 사용해야 한다. AudioUnit은 개발자가 사용할 수 있는 가장 로우 레벨의 인터페이스이다. 오디오 변환 유닛, 오디오 출력 유닛 등을 조합해 오디오 그래프를 생성해서 사용할 수 있고 AudioQueue 또한 이와 같은 AudioUnit을 기반으로 만들어져있다.

Overview

코어오디오를 이용한 음원 재생 튜토리얼에서는 Audio File Service와 AudioQueue 를 이용해 AAC파일을 재생을 할 것이다. 간략하게 다음과 같은 순서를 따라간다

  1. 로컬 경로에 존재하는 AAC파일 에서부터 AudioFileID을 생성한다.
  2. AudioFileID 에서 오디오 데이터 포맷 (AudioStreamBasicDescription)을 추출한다.
  3. AudioQueueRef 를 생성한다. 이 때 2번에서 구한 오디오 데이터 포맷을 적절히 세팅해 준다. 또한 큐에 있는 버퍼를 비웠을 때 불릴 callback 함수를 설정해 준다.
  4. 버퍼를 생성해서 오디오 데이터를 채워준다음 AudioQueueRef에 enqueue 해준다.
  5. Callback 함수에서는 빈 버퍼를 인자로 받아서 오디오 데이터를 AudioQueueRef에 enqueue 해 주도록 구현한다.
  6. 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
};

  1. 오디오 큐 버퍼는 3개로 정의한다. (재생용/대기용/채우는용)
  2. AudioStreamBasicDescription는 오디오 스트림의 광범위한 특성를 정의하는 구조체이다.
  3. 재생 오디오 큐
  4. 오디오 큐 버퍼 포인트의 리스트
  5. 오디오 파일 객체
  6. 오디오 큐 버퍼 하나의 사이즈
  7. 다음 재생할 패킷의 위치
  8. playback callback 이 불렸을 때 한번에 읽을 packet 의 갯수
  9. VBR 오디오인 경우 필요한 packet 정보
  10. 현재 재생 오디오큐가 재생 중인지

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를 프린트 해 보면 패킷마다 값이 다르기 때문에 mBytesPerPacketmFramesPerPacket 의 값은 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
}

  1. ASBDesc.mFramesPerPacket != 0이라면 ASDB에 패킷당 프레임 갯수가 전체 오디오 데이터를 통틀어 일정하다는 뜻이다. 그렇다면 위와 같은 수식으로 주어진 시간 (=seconds)에 처리할 수 있는 패킷의 갯수 (=numPacketsForTime) 를 구할 수 있고 이를 통해 주어진 시간에 필요한 버퍼의 크기(=outBufferSize)를 계산할 수 있다.
  2. 패킷당 프레임 갯수가 다른 경우라면 maxBufferSizemaxPacketSize (AudioFileGetProperty 함수로 구할 수 있음) 를 비교해서 더 큰 값으로 설정해 준다.
  3. 얻은 값이 너무 크다면 maxBufferSize 에 맞춰준다
  4. 얻은 값이 너무 작다면 minBufferSize 에 맞춰준다
  5. 버퍼의 크기와 최대 패킷 크기를 안다면 콜백 한번에 읽을 수 있는 패킷 갯수를 계산한다.

그럼 이제 위 함수를 이용해서 버퍼 사이즈와 읽을 패킷 갯수를 실제로 계산해보자.

UInt32 maxPacketSize;
UInt32 propertySize = sizeof (maxPacketSize);
AudioFileGetProperty (                               // 1
    mPlayer.mAudioFile,                               
    kAudioFilePropertyPacketSizeUpperBound,          
    &propertySize,                                   
    &maxPacketSize                                   
);

DeriveBufferSize (                                   // 2
    mPlayer.mDataFormat,                              
    maxPacketSize,                                   
    0.5,                                             
    &mPlayer.bufferByteSize,                          
    &mPlayer.mNumPacketsToRead                        
);

  1. AudioFileGetProperty함수를 이용해서 maximum packet size를 구한다.
  2. DeriveBufferSize함수를 이용해서 bufferByteSizemNumberPacketToRead를 구한다. 여기서 주어진 시간은 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;
}

  1. 해당 오디오 데이터가 VBR인지 구분하는 방법은 간단하다. 위에서 구한 ASBDmBytesPerPacket 이나 mFramesPerPacket 의 값이 0인지 확인하면 된다.
  2. 만약 VBR format 이라면 AudioStreamPacketDescription 의 사이즈 x 한번에 읽을 패킷 갯수 (= mNumPacketsToRead) 만큼 메모리를 할당해 준다. 이 정보는 오디오 큐 콜백 함수에서 오디오 데이터를 읽어들일 때 각각의 패킷을 분석할 때 쓰인다.
  3. 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);                                
}

  1. Magic cookie 데이터의 크기를 잘 모르기 때문에 AudioFileGetPropertyInfo 함수를 이용해서 오디오 데이터에 실제로 magic cookie가 존재하는지, 존재 한다면 사이즈를 구해서 cookieSize 에 할당해 준다.
  2. AudioFileGetPropertyInfo 함수에 대한 결과가 error가 아니고 (즉 magic cookie가 존재하고) 사이즈를 구했다면 AudioFileGetProperty 함수를 이용해서 실제로 magic cookie 정보를 불러온다.
  3. 오디오 데이터에서 불러온 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 
   )

  1. Callback에서 넘어온 aqData를 직접 구현한 사용자 구조체로 캐스팅
  2. 실제로 읽은 데이터의 길이를 저장하기 위한 변수
  3. 몇개의 패킷을 읽어야 하는지 (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

파일 재생을 마쳤을 때는 꼭 AudioFileIDAudioQueue를 정리해 줘야 한다. AudioQueueDispose 함수의 두번째 인자는 dispose를 바로 진행할 지, enqueue 된 버퍼를 전부 소진 한 후 dispose 할지 결정한다.

AudioQueueDispose (                            
    aqData.mQueue,                             
    true                                       
);

AudioFileClose (aqData.mAudioFile);            

free (aqData.mPacketDescs);