세상을 예술로, 시각을 아름답게.
Dongmins' Blog
음성 통신 2편: WASAPI로 마이크 녹음 프로그램 만들기 (C++)
#audio#c++#win32#windows
240429 09:00 views: 226

안녕하세요, 신동민입니다..! 음성 통신을 위해서는 마이크에서 나온 PCM 데이터를 송수신해야 합니다. 그렇다면 어떻게 마이크에서 PCM 데이터를 가져올 수 있을까요? 또 이 데이터를 어떻게 스피커를 통해 재생할 수 있을까요?

Windows 운영체제에서는 고급 오디오 애플리케이션 개발을 위한 Windows Core Audio API를 제공합니다. 이번 포스트에서는 Windows Core Audio API를 활용해 마이크에서 오디오 데이터를 캡처하고 그것을 WAV파일로 저장하는 녹음 프로그램을 작성해 보겠습니다.

Windows에서 만든 API이기 때문에 공식 문서가 매우 잘 나와 있습니다... Windows Core Audio API에 대해 더 자세히 알고 싶으시다면 마이크로소프트에서 제공하는 문서를 읽어보시는 것을 매우 추천드립니다. 이 포스트에서는 자세한 내용은 생략하고 API의 사용 예시만 간단히 다루겠습니다.


Windows Core Audio API에 대하여

Windows Core Audio API는 Windows Vista 이후로 새로 도입된 전문 오디오 또는 실시간 오디오 애플리케이션 개발을 위한 Windows API의 한 세트입니다. 이 API는 Component Object Model (COM) 구조를 기반으로 하며, 오디오 데이터의 캡처, 처리 및 렌더링을 위한 강력한 프로그래밍 모델을 제공합니다.

이 API는 다음과 같은 네 가지의 API들로 구성되어 있습니다.

  • Multimedia Device (MMDevice) API: 컴퓨터에 연결된 스피커나 마이크 같은 오디오 엔드포인트 디바이스들을 열거하기 위한 API 입니다. 현재는 Windows RT API의 Windows.Devices.Enumeration으로 대체되어 일반적인 사용을 권장하진 않으나 여전히 많이 사용되고 있습니다.
  • Windows Audio Session API (WASAPI): 만약 DAW를 접해보셨다면 익숙하실 수도 있습니다. WASAPI는 오디오 엔드포인트 디바이스에서 데이터를 캡처하거나 렌더링을 할 때 쓰이는 API 입니다. 마이크에서 오디오 데이터를 가져오거나 오디오 데이터를 스피커로 재생할 때 사용됩니다.
  • DeviceTopology API: 오디오 어댑터의 다양한 내부 기능을 제어하기 위해서 사용됩니다. 지금은 더이상 사용을 권장하진 않는다고 합니다.
  • EndpointVolume API: 특수한 상황에서(전용 모드 액세스) 오디오 엔드포인트 디바이스의 볼륨을 제어하는 API 입니다. 지금은 더이상 사용이 권장되지 않습니다.

이 중 Multimedia Device API와 Windows Audio Session API를 사용하여 녹음기 프로그램을 만들어 보겠습니다.


녹음 프로그램 아키텍처

프로그램을 만들기 전에 프로그램의 흐름을 생각해 봅시다.

  • 1. 사용할 오디오 캡처 디바이스 선택: 컴퓨터에 여러 마이크, 즉 오디오 캡처 디바이스가 있을 수 있습니다. 그 중에서 사용할 디바이스를 선택합니다. 디바이스를 열거하고 선택할 때는 MMDevice API가 쓰입니다.
  • 2. 해당 디바이스로 오디오 클라이언트 활성화: 오디오 클라이언트는 WASAPI에서 오디오 장치에 대한 애플리케이션의 액세스 포인트입니다. 이를 활성화해서 오디오 스트림을 관리하고 데이터를 랜더링 또는 캡처 할 수 있습니다.
  • 3. 오디오 캡처 시작: 오디오 캡처 클라이언트를 사용해서 선택한 오디오 엔드포인트 디바이스의 PCM 데이터를 계속 가져옵니다.
  • 4. 캡처한 데이터를 적절히 처리: 이번 포스트에서는 녹음 프로그램을 만들것이므로 캡처한 PCM 데이터를 메모리에 계속 저장해두는 과정이 될 것입니다. 만약 오디오 통신 또는 스트리밍 애플리케이션을 만든다고 하면 이 부분이 PCM 데이터를 송신하는 부분이 될 것입니다.
  • 5. 사용자의 액션에 따라 캡처를 중단 및 프로그램 종료: 사용자의 입력을 받으면 캡처를 중단하고 지금까지 메모리에 올려둔 PCM 데이터를 wav파일로 저장합니다. 모든 작업이 끝나면 프로그램이 종료됩니다.

마이크 데이터를 캡처하는 코드를 작성해보자!

그럼 구상했던 내용대로 코드를 작성 해볼까요? 전체 코드의 양이 꽤 길어서 나눠서 설명드린 뒤 최종적인 코드를 보여드리겠습니다. 대부분의 코드는 여기에서 가져왔습니다.

// main.cpp
#define EXIT_ON_ERROR(hres) \
    if (FAILED(hres)) {     \
        goto Exit;          \
    }
#define SAFE_RELEASE(punk)  \
    if ((punk) != NULL) {   \
        (punk)->Release();  \
        (punk) = NULL;      \
    }

constexpr auto REFTIMES_PER_SEC = 10000000;
constexpr auto REFTIMES_PER_MILLISEC = 10000;

const CLSID CLSID_MMDeviceEnumerator = __uuidof(MMDeviceEnumerator);
const IID IID_IMMDeviceEnumerator = __uuidof(IMMDeviceEnumerator);
const IID IID_IAudioClient = __uuidof(IAudioClient);
const IID IID_IAudioCaptureClient = __uuidof(IAudioCaptureClient);

CaptureManager captureManager; // todo
bool bDone = false;

HRESULT RecordAudioStream() {
    HRESULT hr;
    REFERENCE_TIME hnsRequestedDuration = REFTIMES_PER_SEC;
    REFERENCE_TIME hnsActualDuration;
    UINT32 bufferFrameCount;
    UINT32 numFramesAvailable;
    IMMDeviceEnumerator* pEnumerator = NULL;
    IMMDevice* pDevice = NULL;
    IAudioClient* pAudioClient = NULL;
    IAudioCaptureClient* pCaptureClient = NULL;
    WAVEFORMATEX* pwfx = NULL;
    UINT32 packetLength = 0;
    BYTE* pData;
    DWORD flags;

    hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED);
    EXIT_ON_ERROR(hr);

    hr = CoCreateInstance(
        CLSID_MMDeviceEnumerator,
        NULL,
        CLSCTX_ALL,
        IID_IMMDeviceEnumerator,
        (void**)&pEnumerator
    );
    EXIT_ON_ERROR(hr);

    hr = pEnumerator->GetDefaultAudioEndpoint(
        eCapture,
        eConsole,
        &pDevice
    );
    EXIT_ON_ERROR(hr);

    hr = pDevice->Activate(
        IID_IAudioClient,
        CLSCTX_ALL,
        NULL,
        (void**)&pAudioClient
    );
    EXIT_ON_ERROR(hr);

    hr = pAudioClient->GetMixFormat(&pwfx);
    EXIT_ON_ERROR(hr);

    pwfx->wFormatTag = WAVE_FORMAT_PCM;
    pwfx->cbSize = 0;

    hr = pAudioClient->Initialize(
        AUDCLNT_SHAREMODE_SHARED,
        0,
        hnsRequestedDuration,
        0,
        pwfx,
        NULL
    );
    EXIT_ON_ERROR(hr);

    hr = pAudioClient->GetBufferSize(&bufferFrameCount);
    EXIT_ON_ERROR(hr);

    hr = pAudioClient->GetService(
        IID_IAudioCaptureClient,
        (void**)&pCaptureClient
    );
    EXIT_ON_ERROR(hr);

    captureManager.SetFormat(pwfx); // todo

    hnsActualDuration = (double)REFTIMES_PER_SEC * bufferFrameCount / pwfx->nSamplesPerSec;

    hr = pAudioClient->Start();
    EXIT_ON_ERROR(hr);

    while (!bDone) {
        Sleep(hnsActualDuration / REFTIMES_PER_MILLISEC / 2);

        hr = pCaptureClient->GetNextPacketSize(&packetLength);
        EXIT_ON_ERROR(hr);

        while (packetLength != 0) {
            hr = pCaptureClient->GetBuffer(
                &pData,
                &numFramesAvailable,
                &flags,
                NULL,
                NULL
            );
            EXIT_ON_ERROR(hr);

            if (flags & AUDCLNT_BUFFERFLAGS_SILENT) {
                pData = NULL;
            }

            captureManager.QueueData(pData, numFramesAvailable); // todo

            hr = pCaptureClient->ReleaseBuffer(numFramesAvailable);
            EXIT_ON_ERROR(hr);

            hr = pCaptureClient->GetNextPacketSize(&packetLength);
            EXIT_ON_ERROR(hr);
        }
    }

    hr = pAudioClient->Stop();
    EXIT_ON_ERROR(hr);

Exit:
    CoTaskMemFree(pwfx);
    SAFE_RELEASE(pEnumerator);
    SAFE_RELEASE(pDevice);
    SAFE_RELEASE(pAudioClient);
    SAFE_RELEASE(pCaptureClient);
    CoUninitialize();

    return hr;
}

오마이갓! 코드가 너무 길어요! 하나하나 차근차근 살펴보겠습니다.

그 전에 알아야 할 것이 있습니다. 코드를 둘러보시면 HRESULT, CLSID, IID 등 익숙하지 않은 타입이 있습니다. Windows Core Audio API를 사용하기 위해서는 Component Object Model(COM)에 대해서 알아야 합니다.

COM은 마이크로소프트에서 만든 소프트웨어 컴포넌트 기술입니다. 서로 다른 프로그래밍 언어로 만들어진 컴퓨터 프로그램들이 서로 상호작용 할 수 있도록 합니다. COM 객체는 고유의 인터페이스를 통해 접근되고 인터페이스는 객체가 제공하는 메서드들의 정의를 포함하고 있어서 객체의 내부 구현을 숨기면서도 필요한 기능을 제공할 수 있습니다. COM에 대한 자세한 설명은 이 포스트의 범위에서 벗어나니, 참고 자료를 드리고 넘어가겠습니다.

또한 함수 또는 메서드의 인자값들에 대한 설명은 생략하겠습니다. 대신 공식 문서 링크에 들어가시면 각 인자값들의 명세를 확인하실 수 있습니다.


먼저 매크로 함수와 전역 변수들부터 살펴보겠습니다.

#define EXIT_ON_ERROR(hres) \
    if (FAILED(hres)) {     \
        goto Exit;          \
    }
#define SAFE_RELEASE(punk)  \
    if ((punk) != NULL) {   \
        (punk)->Release();  \
        (punk) = NULL;      \
    }

매크로 함수로 EXIT_ON_ERRORSAFE_RELEASE를 정의해줍니다. 프로그램에서 다양한 COM 메서드를 호출할 때 각 메서드는 HRESULT 타입의 값을 반환하여 호출의 성공 여부를 나타냅니다. FAILED 매크로를 사용해서 만약 반환값이 정상값이 아니라면 goto문으로 오류 처리를 하는 코드 블록으로 점프합니다.

COM 객체는 참조 카운팅을 기반으로 메모리 관리를 수행합니다. SAFE_RELEASE 매크로는 객체의 Release 메서드를 호출해서 참조 카운트를 감소시키고 카운트가 0이 되면 자동으로 해제되도록 합니다.


constexpr auto REFTIMES_PER_SEC = 10000000;
constexpr auto REFTIMES_PER_MILLISEC = 10000;

REFERENCE_TIME은 Windows API에서 사용되는 타입으로 오디오나 비디오 프로그래밍에서 시간을 나노초 단위로 표현할 때 사용됩니다. 1 REFERENCE_TIME 단위는 100ns이며 즉 1초는 10,000,000 REFERENCE_TIME 입니다.

가독성을 위해서 초당 REFTIME과 ms당 REFTIME을 상수로 정의해줍니다.


const CLSID CLSID_MMDeviceEnumerator = __uuidof(MMDeviceEnumerator);
const IID IID_IMMDeviceEnumerator = __uuidof(IMMDeviceEnumerator);
const IID IID_IAudioClient = __uuidof(IAudioClient);
const IID IID_IAudioCaptureClient = __uuidof(IAudioCaptureClient);

필요한 COM 인터페이스의 CLSID(Class ID)와 IID(Interface ID)를 정의합니다. Microsoft Visual C++에서만 사용되는 __uuidof 연산자로 클래스의 UUID를 가져옵니다. 이 연산자는 해당 클래스 또는 인터페이스의 고유 식별자를 쉽게 찾을수 있도록 도와줍니다.


hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED);
EXIT_ON_ERROR(hr);

hr = CoCreateInstance(
    CLSID_MMDeviceEnumerator,
    NULL,
    CLSCTX_ALL,
    IID_IMMDeviceEnumerator,
    (void**)&pEnumerator
);
EXIT_ON_ERROR(hr);

COM 라이브러리를 사용하기 전에 COM을 사용하는 스레드는 CoInitializeEx 함수를 호출하여 COM 라이브러리를 초기화해야 합니다. 두번째 인수로 COINIT_APARTMENTTHREADED를 전달해서 스레드를 아파트 모델로 설정합니다. 즉 해당 스레드에서 생성되는 COM 객체들이 같은 스레드의 요청에만 응답하도록 합니다.

CoCreateInstance 함수로 지정된 CLSID를 사용하여 COM 객체의 새 인스턴스를 만들 수 있습니다. MMDeviceEnumerator의 CLSID를 인수로 넘겨서 해당 클래스의 인스턴스를 생성하고 그것을 pEnumerator 포인터에 객체의 인스턴스를 할당합니다.


hr = pEnumerator->GetDefaultAudioEndpoint(
    eCapture,
    eConsole,
    &pDevice
);
EXIT_ON_ERROR(hr);

hr = pDevice->Activate(
    IID_IAudioClient,
    CLSCTX_ALL,
    NULL,
    (void**)&pAudioClient
);
EXIT_ON_ERROR(hr);

오디오 애플리케이션에서 중요한 첫단계는 적절한 오디오 엔드포인트 디바이스를 선택하는 것입니다. GetDefaultAudioEndpoint 메서드로 시스템의 기본 오디오 엔드포인트 디바이스의 정보를 pDevice 포인터에 저장합니다. 첫번째 인자에 eCapture를 넘겨서 오디오 캡처 디바이스 중 기본 디바이스를 선택하도록 합니다. 만약 기본 스피커를 고르고 싶다면 eRender를 넘기면 됩니다.

선택한 디바이스로부터 Activate 메서드로 오디오 클라이언트 인터페이스를 활성화합니다. 이 메서드로 IAudioClient COM 인터페이스의 인스턴스가 반환되며 이를 통해 오디오 데이터의 캡처 또는 렌더링을 시작할 수 있습니다.


hr = pAudioClient->GetMixFormat(&pwfx);
EXIT_ON_ERROR(hr);

pwfx->wFormatTag = WAVE_FORMAT_PCM;
pwfx->cbSize = 0;

/* Alternative!
pwfx = new WAVEFORMATEX();

pwfx->wFormatTag      = WAVE_FORMAT_PCM;
pwfx->nChannels       = 1;
pwfx->nSamplesPerSec  = 48000;
pwfx->nAvgBytesPerSec = 96000;
pwfx->nBlockAlign     = 2;
pwfx->wBitsPerSample  = 16;
pwfx->cbSize          = 0;
*/

hr = pAudioClient->Initialize(
    AUDCLNT_SHAREMODE_SHARED,
    0,
    hnsRequestedDuration,
    0,
    pwfx,
    NULL
);
EXIT_ON_ERROR(hr);

오디오 스트림의 처리를 시작하기 전에 적절한 오디오 포맷을 설정해야 합니다. GetMixFormat 메서드로 오디오 엔드포인트 디바이스 즉 마이크의 입력 형식을 가져온 후 그것을 pwfx 포인터에 저장합니다.

기본 포맷을 성공적으로 검색한 후 이 포맷을 LPCM으로 설정하여 오디오 데이터의 처리를 단순화합니다. wFormatTag 멤버값을 WAVE_FORMAT_PCM으로 설정합니다. 이때 cbSize는 무시되므로 값을 0으로 설정합니다.

GetMixFormat 메서드를 사용하지 않고 WAVEFORMATEX 구조체를 직접 만들고 필요한 파라미터를 채워넣는것도 가능합니다. 이때 주의할 점은 선택한 포맷이 오디오 엔드포인트 디바이스에서 호환되지 않는 형식이라면 오류가 발생합니다. 이런 경우 IsFormatSupported 메서드를 사용해서 해당 포맷이 오디오 디바이스와 호환이 되는지 여부를 확인 후에 사용하는 것이 좋습니다.

윈도우의 설정에서 오디오 엔드포인트 디바이스의 지원하는 오디오 포맷을 확인할 수 있습니다. 저같은 경우 Apogee 오디오 인터페이스를 사용중이라 지원하는 형식이 다양합니다.

0
윈도우 설정에서 확인한 오디오 디바이스의 지원 오디오 포맷들

이후에 설정한 포맷으로 오디오 클라이언트를 초기화합니다. Initialize 메서드로 스트림의 버퍼 크기와 기타 파라미터를 설정해서 오디오 캡처 또는 렌더링을 준비합니다.

이전 포스트를 읽으셨다면 wFormatTag, nChannels 등등 pwfx의 멤버 이름이 익숙하실 것입니다. 이 구조체는 WAVEFORMATEX 구조체로 .wav 파일의 fmt 청크에서도 사용되었습니다!

참고로 이전 포스트에서 만들었던 fmt 청크 구조체의 멤버는 PCMWAVEFORMAT 구조체로 나타낼 수 있습니다.

// Example of using PCMWAVEFORMAT structure
#include <mmeapi.h>

typedef struct {
    char chunkID[4];
    uint32_t chunkSize;
    PCMWAVEFORMAT format;
} FMT;

hr = pAudioClient->GetBufferSize(&bufferFrameCount);
EXIT_ON_ERROR(hr);

hr = pAudioClient->GetService(
    IID_IAudioCaptureClient,
    (void**)&pCaptureClient
);
EXIT_ON_ERROR(hr);

captureManager.SetFormat(pwfx); // todo

hnsActualDuration = (double)REFTIMES_PER_SEC * bufferFrameCount / pwfx->nSamplesPerSec;

hr = pAudioClient->Start();
EXIT_ON_ERROR(hr);

오디오 스트림이 초기화 됐다면 GetBufferSize 메서드로 오디오 스트림의 버퍼 사이즈를 가져옵니다. 위에서 초기화할때 요청한 버퍼의 크기와 다를 수 있으니 꼭 GetBufferSize 메서드로 실제 버퍼 사이즈를 알아야 합니다.

이제 오디오 데이터를 캡처하기 위해, 오디오 클라이언트에서 IAudioCaptureClient 인터페이스를 가져와야 합니다. GetService 메서드를 사용해서 오디오 캡처 서비스에 접근합니다.

그리고 captureManager 객체에 오디오의 포맷을 넘겨줍니다. captureManager에 대해서는 뒤에서 다루겠습니다.

초기화된 버퍼의 크기를 바탕으로 버퍼가 담을 수 있는 최대 시간을 계산한 뒤 hnsActualDuration 변수에 저장합니다.

마지막으로 Start 메서드로 오디오 스트림을 시작합니다. 이제 마이크 데이터를 캡처할 일만 남았습니다!


while (!bDone) {
    Sleep(hnsActualDuration / REFTIMES_PER_MILLISEC / 2);

    hr = pCaptureClient->GetNextPacketSize(&packetLength);
    EXIT_ON_ERROR(hr);

    while (packetLength != 0) {
        hr = pCaptureClient->GetBuffer(
            &pData,
            &numFramesAvailable,
            &flags,
            NULL,
            NULL
        );
        EXIT_ON_ERROR(hr);

        if (flags & AUDCLNT_BUFFERFLAGS_SILENT) {
            pData = NULL;
        }

        captureManager.QueueData(pData, numFramesAvailable); // todo

        hr = pCaptureClient->ReleaseBuffer(numFramesAvailable);
        EXIT_ON_ERROR(hr);

        hr = pCaptureClient->GetNextPacketSize(&packetLength);
        EXIT_ON_ERROR(hr);
    }
}

오디오 스트림이 시작된 뒤 오디오 데이터가 계속 버퍼에 쌓이게 됩니다. 이제 반복문 안에서 계속 오디오 스트림 버퍼에 접근해서 PCM 데이터를 가져올 것입니다. 우선 Sleep 함수로 오디오 버퍼 길이의 절반만큼 일시정지합니다. 이렇게 해서 너무 잦은 버퍼 접근을 줄입니다.

버퍼에는 오디오 패킷이 계속해서 쌓이고 있습니다. 오디오 패킷은 단순히 PCM 데이터 덩어리들입니다. GetNextPacketSize 메서드로 버퍼에 적재된 다음 패킷의 길이를 가져옵니다. 참고로 패킷의 길이의 단위는 Byte가 아닌 Frame 입니다.

또 다시 반복문을 만들어서 버퍼의 모든 오디오 패킷을 다 사용할때까지 반복합니다. GetBuffer 메서드로 오디오 패킷에 접근해서 PCM 데이터를 가져옵니다! PCM 데이터는 pData에 저장됩니다.

flags에는 오디오 엔드포인트 버퍼의 상태가 저장됩니다. 값은 0 또는 _AUDCLNT_BUFFERFLAGS 열거형 일 수 있습니다. 만약 flags의 값이 AUDCLNT_BUFFERFLAGS_SILENT인 경우 오디오 데이터 대신 조용한 즉 음소거된 데이터가 있다는 것을 의미합니다. 이때 pDataNULL로 처리해줍니다.

이제 captureManager 객체에 pDatanumFramesAvailable을 넘겨줍니다. captureManager 객체는 이후에 넘겨받은 오디오 패킷들을 기록하고 녹음이 종료되면 기록했던 패킷들을 wav파일로 저장할 것입니다.

버퍼를 사용한 만큼 Release를 해줘야 합니다. ReleaseBuffer 메서드로 사용한 frame만큼 버퍼를 Release 해줍니다.

마지막으로 GetNextPacketSize 메서드로 모든 오디오 패킷을 사용할때까지 반복문을 돌아줍니다.


    hr = pAudioClient->Stop();
    EXIT_ON_ERROR(hr);

Exit:
    CoTaskMemFree(pwfx);
    // delete pwfx;
    SAFE_RELEASE(pEnumerator);
    SAFE_RELEASE(pDevice);
    SAFE_RELEASE(pAudioClient);
    SAFE_RELEASE(pCaptureClient);
    CoUninitialize();

    return hr;

거의 다 끝났습니다. 사용자의 입력으로 모든 반복문이 종료되었다면, 즉 bDone이 true가 되었다면 캡처를 중단해야겠죠. Stop 메서드로 오디오 스트림을 종료합니다.

pwfx 말고 다른 객체들은 IUnknown 인터페이스의 자손들이였습니다. SAFE_RELEASE 매크로 함수로 객체들의 Release 메서드를 호출해서 객체의 참조 카운트를 감소시키고 할당된 메모리를 해제해줍니다.

pwfx같은 경우 CoTaskMemFree 함수로 할당된 메모리를 해제합니다. 그런데 만약 GetMixFormat 메서드가 아니라 new WAVEFORMATEX()pwfx를 동적할당 했다면 delete pwfx로 할당된 메모리를 해제하면 됩니다.

진짜 마지막으로 CoTaskMemFree 함수를 호출해서 스레드에서 사용중인 COM 라이브러리를 종료하고 메모리에서 로드된 라이브러리들을 언로드합니다.


PCM을 핸들링 하는 클래스를 작성해보자!

마이크 입력을 받는 코드를 작성했습니다! 그럼 이제 이 입력받은 데이터들을 메모리에 담아놨다가 wav파일을 저장하는 클래스의 코드를 작성해볼까요? 이전 포스트에서 C++로 wav파일을 다뤄봤다면 어렵지 않을거에요. 먼저 전체 코드를 보겠습니다.

// CaptureManager.h
#pragma once

#include <mmdeviceapi.h>
#include <vector>


struct Packet {
    int size;
    char* pcm;
};

class CaptureManager {
private:
    WAVEFORMATEX* pwfx;
    std::vector<Packet> queue;
    int size;

public:
    CaptureManager();
    void SetFormat(WAVEFORMATEX* _pwfx);
    void QueueData(BYTE* data, UINT numFramesAvailable);
    void Save();
};

// CaptureManager.cpp
#include "CaptureManager.h"
#include "Wave.h"

#include <fstream>


CaptureManager::CaptureManager() {
    size = 0;
    pwfx = new WAVEFORMATEX();
}

void CaptureManager::SetFormat(WAVEFORMATEX* _pwfx) {
    memcpy(pwfx, _pwfx, sizeof(WAVEFORMATEX));
}

void CaptureManager::QueueData(BYTE* data, UINT numFramesAvailable) {
    int byteSize = numFramesAvailable * (pwfx->nChannels * pwfx->wBitsPerSample / 8);

    char* pcm = new char[byteSize];
    memcpy(pcm, data, byteSize);
    Packet packet{ byteSize, pcm };
    queue.push_back(packet);

    size += byteSize;
}

void CaptureManager::Save() {
    WAVE_HEADER header(
        pwfx->nSamplesPerSec,
        pwfx->nChannels,
        pwfx->wBitsPerSample,
        size
    );
    std::ofstream wavFile("result.wav", std::ios::binary);
    wavFile.write(reinterpret_cast<char*>(&header), sizeof(WAVE_HEADER));

    for (auto& packet : queue) {
        wavFile.write(packet.pcm, packet.size);
    }

    wavFile.close();
}

어렵지 않은 코드입니다. 총 3개의 메서드가 있고 각각의 역할은 다음과 같습니다.

  • SetFormat: 오디오 포맷을 동기화 합니다.
  • QueueData: 받은 PCM 데이터를 클래스 내부 멤버 queue 벡터에 값을 집어넣어줍니다. PCM 값만 집어넣는것이 아닌 Packet 구조체에 numFramesAvailable까지 담아서 벡터에 집어넣습니다.
  • Save: 지금까지 queue에 저장된 모든 데이터를 바이너리 형태로 파일에 작성합니다. 이전 포스트에서 더 자세히 다루고 있으니 확인해주세요.

Wave.h는 이전에 썼던 헤더 파일을 그대로 사용하시면 됩니다.


사용자 입력 스레드를 작성해보자!

이제 진짜 마지막입니다. 마이크 캡처 스레드가 계속 돌고 있을 때 사용자의 액션에 따라 캡처가 중단되어야 합니다. 즉 그 액션을 받는 스레드를 만들어보겠습니다.

// main.cpp
#include <iostream>
#include <thread>

#include "CaptureManager.h"

/* ... */

CaptureManager captureManager;
bool bDone = false;

void RecordControl() {
    std::cin.get(); // Wait for 'Enter'
    bDone = true;
    Sleep(1000);
    captureManager.Save();
}

int main() {
    std::thread recordThread(RecordAudioStream);
    std::thread controlThread(RecordControl);

    recordThread.join();
    controlThread.join();

    return 0;
}

RecordControl 함수를 만들어서 사용자가 엔터를 누를때까지 대기했다가 bDone 값을 true로 변경한 뒤 captureManager.Save를 호출하는 단순한 함수입니다.

메인함수 안에서 녹음 스레드와 사용자 입력 대기 스레드 두개를 만든 뒤 실행하면 프로그램 완성입니다!


완성된 코드

그럼 완성된 main.cpp 코드를 보시겠습니다.

// main.cpp
#include <Audioclient.h>
#include <mmdeviceapi.h>
#include <Windows.h>

#include <iostream>
#include <thread>

#include "CaptureManager.h"

#define EXIT_ON_ERROR(hres) \
    if (FAILED(hres)) {     \
        goto Exit;          \
    }
#define SAFE_RELEASE(punk)  \
    if ((punk) != NULL) {   \
        (punk)->Release();  \
        (punk) = NULL;      \
    }

constexpr auto REFTIMES_PER_SEC = 10000000;
constexpr auto REFTIMES_PER_MILLISEC = 10000;

const CLSID CLSID_MMDeviceEnumerator = __uuidof(MMDeviceEnumerator);
const IID IID_IMMDeviceEnumerator = __uuidof(IMMDeviceEnumerator);
const IID IID_IAudioClient = __uuidof(IAudioClient);
const IID IID_IAudioCaptureClient = __uuidof(IAudioCaptureClient);

CaptureManager captureManager;
bool bDone = false;

void RecordControl() {
    std::cin.get();
    bDone = true;
    Sleep(1000);
    captureManager.Save();
}

HRESULT RecordAudioStream() {
    HRESULT hr;
    REFERENCE_TIME hnsRequestedDuration = REFTIMES_PER_SEC;
    REFERENCE_TIME hnsActualDuration;
    UINT32 bufferFrameCount;
    UINT32 numFramesAvailable;
    IMMDeviceEnumerator* pEnumerator = NULL;
    IMMDevice* pDevice = NULL;
    IAudioClient* pAudioClient = NULL;
    IAudioCaptureClient* pCaptureClient = NULL;
    WAVEFORMATEX* pwfx = NULL;
    UINT32 packetLength = 0;
    BYTE* pData;
    DWORD flags;

    hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED);
    EXIT_ON_ERROR(hr);

    hr = CoCreateInstance(
        CLSID_MMDeviceEnumerator,
        NULL,
        CLSCTX_ALL,
        IID_IMMDeviceEnumerator,
        (void**)&pEnumerator
    );
    EXIT_ON_ERROR(hr);

    hr = pEnumerator->GetDefaultAudioEndpoint(
        eCapture,
        eConsole,
        &pDevice
    );
    EXIT_ON_ERROR(hr);

    hr = pDevice->Activate(
        IID_IAudioClient,
        CLSCTX_ALL,
        NULL,
        (void**)&pAudioClient
    );
    EXIT_ON_ERROR(hr);

    hr = pAudioClient->GetMixFormat(&pwfx);
    EXIT_ON_ERROR(hr);

    pwfx->wFormatTag = WAVE_FORMAT_PCM;
    pwfx->cbSize = 0;

    hr = pAudioClient->Initialize(
        AUDCLNT_SHAREMODE_SHARED,
        0,
        hnsRequestedDuration,
        0,
        pwfx,
        NULL
    );
    EXIT_ON_ERROR(hr);

    hr = pAudioClient->GetBufferSize(&bufferFrameCount);
    EXIT_ON_ERROR(hr);

    hr = pAudioClient->GetService(
        IID_IAudioCaptureClient,
        (void**)&pCaptureClient
    );
    EXIT_ON_ERROR(hr);

    captureManager.SetFormat(pwfx);

    hnsActualDuration = (double)REFTIMES_PER_SEC * bufferFrameCount / pwfx->nSamplesPerSec;

    hr = pAudioClient->Start();
    EXIT_ON_ERROR(hr);

    while (!bDone) {
        Sleep(hnsActualDuration / REFTIMES_PER_MILLISEC / 2);

        hr = pCaptureClient->GetNextPacketSize(&packetLength);
        EXIT_ON_ERROR(hr);

        while (packetLength != 0) {
            hr = pCaptureClient->GetBuffer(
                &pData,
                &numFramesAvailable,
                &flags,
                NULL,
                NULL
            );
            EXIT_ON_ERROR(hr);

            if (flags & AUDCLNT_BUFFERFLAGS_SILENT) {
                pData = NULL;
            }

            captureManager.QueueData(pData, numFramesAvailable);

            hr = pCaptureClient->ReleaseBuffer(numFramesAvailable);
            EXIT_ON_ERROR(hr);

            hr = pCaptureClient->GetNextPacketSize(&packetLength);
            EXIT_ON_ERROR(hr);
        }
    }

    hr = pAudioClient->Stop();
    EXIT_ON_ERROR(hr);

Exit:
    CoTaskMemFree(pwfx);
    SAFE_RELEASE(pEnumerator);
    SAFE_RELEASE(pDevice);
    SAFE_RELEASE(pAudioClient);
    SAFE_RELEASE(pCaptureClient);
    CoUninitialize();

    return hr;
}

int main() {
    std::thread recordThread(RecordAudioStream);
    std::thread controlThread(RecordControl);

    recordThread.join();
    controlThread.join();

    return 0;
}

코드를 빌드하고 실행하면 녹음이 바로 시작됩니다. 콘솔에 엔터를 누르면 녹음이 종료되고 프로그램이 종료됩니다. 이후 같은 디렉토리에 result.wav 파일이 생성되어 있습니다.


마무리

원시적인 녹음기 프로그램을 C++과 Windows Core Audio API를 사용해서 작성해 보았습니다. 하지만 실제 오디오 어플리케이션은 이렇게 단순하지 않습니다. 디바이스 연결이 끊겼을 때의 오류 처리, 오디오 포맷을 맞추기 위한 업샘플링 또는 다운샘플링 로직 등 생각해야될 케이스가 너무나 많습니다.

이번 포스트에서 제공한 코드 예제와 설명은 오디오 프로그래밍의 기초를 이해하는데 도움을 주기 위한 것입니다. 하지만 제대로된 오디오 애플리케이션을 개발하기 위해서는 이러한 기본적인 내용을 넘어서 더 많은 학습과 실습이 필요합니다. MS 문서를 읽어보시면서 다양한 Windows Core Audio API의 기능들을 직접 탐구해보시는 것을 강력하게 권장드립니다.

궁금한점이 있으시면 댓글 또는 메일로 언제든지 물어보세요! 긴 글 읽어주셔서 감사합니다. 다음 포스트에서 뵙겠습니다!


신동민 ENTP shin@dongmins.com
240429 11:26
앞으로 32번 더 읽으면 되겠네요. 감사합니다.

Password and content are required fields.