자료실

Win32 API만을 이용한 Mp3 재생 예제 날짜:2021-8-19 12:30:17 조회수:150
작성자 : 소년가장
포인트 : 418
가입일 : 2020-02-02 00:50:03
방문횟수 : 50
글 40개, 댓글 27개
소개 : 자기소개
작성글 보기
쪽지 보내기
MP3는 예상 외로 공개 포맷이 아니며 라이센스가 걸려 있는 폐쇄 포맷입니다.
그래서 OGG나 Wav와는 달리 재생하려면 FMOD, BASS 같은 유료 라이브러리를 사용해야 합니다.
이런 라이브러리들은 성능은 물론 좋지만 용량이 커 불편한 면도 있습니다.
그래서 순수한 Win32 API만으로 MP3를 재생하는 예제를 만들어 봤습니다.
MCI를 사용하면 라이브러리 없이도 Mp3 재생 정도는 할 수 있습니다.



예제이니만큼 기능은 단순합니다.

- 재생/일시정지/재개
- 이전/다음곡
- 현재 재생 위치 보여 주기
- 임의의 재생 위치로 이동

이 정도입니다. 멀티미디어 재생기로는 가장 기본적이라고 할 수 있죠.
전체 소스는 다음과 같습니다. 

// MCI를 이용한 MP3 재생 샘플 예제. 외부 라이브러리를 일체 사용하지 않는다.
// 이 예제를 실행하려면 C:\Temp에 a.mp3, b.mp3, c.mp3 세 개의 샘플이 미리 준비되어 있어야 한다.
// 2021년 8월 19일 제작
#include <windows.h>
#include <CommCtrl.h>
#include <mmsystem.h>
#pragma comment(lib, "winmm.lib")
#pragma comment(lib, "comctl32.lib")

LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM);
HINSTANCE g_hInst;
HWND hWndMain;
LPCTSTR lpszClass = TEXT("ApiMp3");

int APIENTRY WinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance
    , _In_ LPSTR lpszCmdParam, _In_ int nCmdShow)
{
    HWND hWnd;
    MSG Message;
    WNDCLASS WndClass;
    g_hInst = hInstance;

    WndClass.cbClsExtra = 0;
    WndClass.cbWndExtra = 0;
    WndClass.hbrBackground = (HBRUSH)(COLOR_BTNFACE + 1);
    WndClass.hCursor = LoadCursor(NULL, IDC_ARROW);
    WndClass.hIcon = LoadIcon(NULL, IDI_APPLICATION);
    WndClass.hInstance = hInstance;
    WndClass.lpfnWndProc = WndProc;
    WndClass.lpszClassName = lpszClass;
    WndClass.lpszMenuName = NULL;
    WndClass.style = CS_HREDRAW | CS_VREDRAW;
    RegisterClass(&WndClass);

    hWnd = CreateWindow(lpszClass, lpszClass, WS_OVERLAPPEDWINDOW,
        CW_USEDEFAULT, CW_USEDEFAULT, 350, 150,
        NULL, (HMENU)NULL, hInstance, NULL);
    ShowWindow(hWnd, nCmdShow);

    while (GetMessage(&Message, NULL, 0, 0)) {
        TranslateMessage(&Message);
        DispatchMessage(&Message);
    }
    return (int)Message.wParam;
}

// 재생 및 정보 표시용 컨트롤
HWND stPath, trProg, btnPlay, btnStop, btnPrev, btnNext;
enum { ST_PATH, TR_PROG, BTN_PLAY, BTN_STOP, BTN_PREV, BTN_NEXT };

// 파일 목록은 세 개로 구성되어 있다. 자신이 가진 적당한 샘플로 경로를 바꿔도 상관 없다.
// 실제 프로그램에서는 사용자가 선택한 파일로 목록을 작성한다.
LPCTSTR arMp3[] = { L"c:\\Temp\\a.mp3" , L"c:\\Temp\\b.mp3", L"c:\\Temp\\c.mp3" };
int mp_idx = 0;        // 현재 재생 파일
UINT dev_id = 0;    // 장치 ID

// 현재 재생중인 파일을 표시한다.
void DisplayFileName()
{
    SetWindowText(stPath, arMp3[mp_idx]);
}

// 현재 상태를 조사한다. dev_id는 열려 있는 상태여야 한다.
DWORD GetStatus()
{
    MCI_STATUS_PARMS statusParam;
    DWORD Result;
    TCHAR sError[256];

    statusParam.dwItem = MCI_STATUS_MODE;
    Result = mciSendCommand(dev_id, MCI_STATUS, MCI_STATUS_ITEM, (DWORD)(LPVOID)&statusParam);
    if (Result) {
        mciGetErrorString(Result, sError, 256);
        MessageBox(hWndMain, sError, L"에러 발생", MB_OK);
        return 0;
    }

    return statusParam.dwReturn;
}

// 재생 길이를 조사한다.
DWORD GetLength()
{
    MCI_STATUS_PARMS statusParam;
    DWORD Result;
    TCHAR sError[256];

    statusParam.dwItem = MCI_STATUS_LENGTH;
    Result = mciSendCommand(dev_id, MCI_STATUS, MCI_STATUS_ITEM, (DWORD)(LPVOID)&statusParam);
    if (Result) {
        mciGetErrorString(Result, sError, 256);
        MessageBox(hWndMain, sError, L"에러 발생", MB_OK);
        return 0;
    }

    return statusParam.dwReturn;
}

// 현재 재생 위치를 조사한다.
DWORD GetPosition()
{
    MCI_STATUS_PARMS statusParam;
    DWORD Result;
    TCHAR sError[256];

    statusParam.dwItem = MCI_STATUS_POSITION;
    Result = mciSendCommand(dev_id, MCI_STATUS, MCI_STATUS_ITEM, (DWORD)(LPVOID)&statusParam);
    if (Result) {
        mciGetErrorString(Result, sError, 256);
        MessageBox(hWndMain, sError, L"에러 발생", MB_OK);
        return 0;
    }

    return statusParam.dwReturn;
}

// 재생 및 정지한다. fromPos가 주어지면 이 위치부터 재생한다.
void DoPlay(int fromPos = 0)
{
    DWORD Result;
    MCI_OPEN_PARMS openParam;
    MCI_PLAY_PARMS playParam;
    TCHAR sError[256];
    DWORD status;

    // 열려 있지 않은 상태이면 장치를 열고 재생을 시작한다. 
    if (dev_id == 0) {
        // 장치를 Open하고 ID를 발급받는다.
        openParam.lpstrDeviceType = L"mpegvideo";
        openParam.lpstrElementName = arMp3[mp_idx];
        Result = mciSendCommand(0, MCI_OPEN, MCI_OPEN_TYPE | MCI_OPEN_ELEMENT, (DWORD)(LPVOID)&openParam);
        if (Result) {
            mciGetErrorString(Result, sError, 256);
            MessageBox(hWndMain, sError, L"에러 발생", MB_OK);
            return;
        }
        dev_id = openParam.wDeviceID;

        // 재생을 시작한다. fromPos가 없으면 처음부터 재생한다. 메인 윈도우로 통지를 보낸다.
        playParam.dwCallback = (DWORD)hWndMain;
        DWORD dwFlag = MCI_NOTIFY;
        if (fromPos != 0) {
            playParam.dwFrom = fromPos;
            dwFlag = dwFlag | MCI_FROM;
        }
        Result = mciSendCommand(dev_id, MCI_PLAY, dwFlag, (DWORD)(LPVOID)&playParam);
        if (Result) {
            mciGetErrorString(Result, sError, 256);
            MessageBox(hWndMain, sError, L"에러 발생", MB_OK);
            mciSendCommand(dev_id, MCI_CLOSE, 0, (DWORD)NULL);
            return;
        }

        SetWindowText(btnPlay, L"Pause");
        // 길이 조사해서 트랙바로 범위를 설정한다. 초단위이며 32비트로 범위를 설정해야 한다.
        SendMessage(trProg, TBM_SETRANGEMAX, FALSE, (LPARAM)GetLength());
    } else {
        // 열려 있는 상태이면 현재 상태에 따라 일시정지 또는 재개한다.
        status = GetStatus();
        switch (status) {
        case MCI_MODE_PLAY:
            mciSendCommand(dev_id, MCI_PAUSE, 0, (DWORD)NULL);
            SetWindowText(btnPlay, L"Play");
            break;
        case MCI_MODE_PAUSE:
            mciSendCommand(dev_id, MCI_RESUME, 0, (DWORD)NULL);
            SetWindowText(btnPlay, L"Pause");
            break;
        }
    }
}

LRESULT CALLBACK WndProc(HWND hWnd, UINT iMessage, WPARAM wParam, LPARAM lParam)
{
    HDC hdc;
    PAINTSTRUCT ps;

    switch (iMessage) {
    case WM_CREATE:
        stPath = CreateWindow(TEXT("static"), TEXT("Path"), WS_CHILD | WS_VISIBLE,
            10, 10, 310, 25, hWnd, (HMENU)ST_PATH, g_hInst, NULL);
        trProg = CreateWindow(TRACKBAR_CLASS, NULL, WS_CHILD | WS_VISIBLE,
            10, 40, 310, 25, hWnd, (HMENU)TR_PROG, g_hInst, NULL);
        btnPlay = CreateWindow(TEXT("button"), TEXT("Play"), WS_CHILD | WS_VISIBLE |
            BS_PUSHBUTTON, 10, 70, 70, 25, hWnd, (HMENU)BTN_PLAY, g_hInst, NULL);
        btnStop = CreateWindow(TEXT("button"), TEXT("Stop"), WS_CHILD | WS_VISIBLE |
            BS_PUSHBUTTON, 90, 70, 70, 25, hWnd, (HMENU)BTN_STOP, g_hInst, NULL);
        btnPrev = CreateWindow(TEXT("button"), TEXT("Prev"), WS_CHILD | WS_VISIBLE |
            BS_PUSHBUTTON, 170, 70, 70, 25, hWnd, (HMENU)BTN_PREV, g_hInst, NULL);
        btnNext = CreateWindow(TEXT("button"), TEXT("Next"), WS_CHILD | WS_VISIBLE |
            BS_PUSHBUTTON, 250, 70, 70, 25, hWnd, (HMENU)BTN_NEXT, g_hInst, NULL);
        hWndMain = hWnd;

        InitCommonControls();
        // 프로그래스 갱신용 타이머. 주기적으로 폴링한다. 
        SetTimer(hWnd, 1, 200, NULL);
        DisplayFileName();
        return 0;
    case WM_COMMAND:
        switch (LOWORD(wParam)) {
        case BTN_PLAY:
            DoPlay();
            break;
        case BTN_STOP:
            if (dev_id != 0) {
                mciSendCommand(dev_id, MCI_STOP, 0, (DWORD)NULL);
                SetWindowText(btnPlay, L"Play");
            }
            break;
        case BTN_PREV:
            mp_idx = mp_idx == 0 ? 2 : mp_idx - 1;
            DisplayFileName();
            if (dev_id != 0) {
                mciSendCommand(dev_id, MCI_STOP, MCI_WAIT, (DWORD)NULL);
                // 통지까지 처리하고 완전히 닫을때까지 기다린 후 재생 시작한다.
                SetTimer(hWnd, 99, 100, NULL);
            }
            break;
        case BTN_NEXT:
            mp_idx = mp_idx == 2 ? 0 : mp_idx + 1;
            DisplayFileName();
            if (dev_id != 0) {
                mciSendCommand(dev_id, MCI_STOP, MCI_WAIT, (DWORD)NULL);
                SetTimer(hWnd, 99, 100, NULL);
            }
            break;
        }
        return 0;
    case WM_TIMER:
        switch (wParam) {
        // 프로그래스 진행 타이머
        case 1:
            if (dev_id != 0) {
                int pos = GetPosition();
                SendMessage(trProg, TBM_SETPOS, TRUE, pos);
            }
            break;
        // 이전, 다음곡 재생 시작용 일회용 타이머
        case 99:
            KillTimer(hWnd, 99);
            DoPlay();
            break;
        // 재생 위치 이동용 타이머
        case 98:
            KillTimer(hWnd, 98);
            DoPlay(SendMessage(trProg, TBM_GETPOS, 0, 0));
            break;
        }
        return 0;
    case MM_MCINOTIFY:
        switch (wParam) {
        case MCI_NOTIFY_ABORTED:
            // 멈추면 ABORTED 통지 날라가며 여기서 장치를 닫는다.
            mciSendCommand(LOWORD(lParam), MCI_CLOSE, 0, (DWORD)NULL);
            dev_id = 0;
            break;
        case MCI_NOTIFY_SUCCESSFUL:
            // 장치를 멈춘 후 다음 곡 재생
            mciSendCommand(LOWORD(lParam), MCI_CLOSE, 0, (DWORD)NULL);
            dev_id = 0;
            mp_idx = mp_idx == 2 ? 0 : mp_idx + 1;
            DisplayFileName();
            DoPlay();
            break;
        }
        return 0;
    case WM_HSCROLL:
        if ((HWND)lParam == trProg) {
            if (dev_id != 0) {
                // 위치를 옮기면 장치는 STOP되며 ABORTED 통지가 날라간다.
                //MCI_SEEK_PARMS seekParam;
                //seekParam.dwTo = SendMessage(trProg, TBM_GETPOS, 0, 0);
                //mciSendCommand(dev_id, MCI_SEEK, MCI_TO, (DWORD)(LPVOID)&seekParam);

                // 이럴 바에야 그냥 멈추고 프로그래스 위치에서 다시 시작하는게 더 깔끔하다.
                mciSendCommand(dev_id, MCI_STOP, 0, (DWORD)NULL);

                SetTimer(hWndMain, 98, 100, NULL);
            }
        }
        return 0;
    case WM_PAINT:
        hdc = BeginPaint(hWnd, &ps);
        // 할일 없이 빈둥 빈둥 논다.
        EndPaint(hWnd, &ps);
        return 0;
    case WM_DESTROY:
        // 장치가 열려 있으면 닫고 종료한다.
        if (dev_id != 0) {
            mciSendCommand(dev_id, MCI_STOP, 0, (DWORD)NULL);
        }
        PostQuitMessage(0);
        return 0;
    }
    return(DefWindowProc(hWnd, iMessage, wParam, lParam));
}

첨부한 압축 파일에는 프로젝트와 릴리즈 실행 파일을 포함시켜 두었습니다.
요즘 윈도우 디펜더가 미쳤는지 비주얼 스튜디오로 컴파일한 모든 실행 파일을 바이러스로 진단하여
차단해 버리는 어처구니없는 짓거리를 하고 있습니다.
그래서 불가피하게 압축 파일에 암호를 걸었습니다. 암호는 soen입니다. 
실행 파일이 정 의심스러우시면 프로젝트 열어 컴파일한 후 참고해 주십시오.

 



오늘도 최선을 다 하자.
첨부 파일 210819-123017_ApiMp3.zip(48281 byte). 다운로드 : 9

목록보기 삭제 수정 신고 스크랩

마음은어린이 8월19일 11:14:27  

좋은 자료 감사드립니다.

소년가장 8월22일 11:58:30  

이 코드를 잘 사용했는데 여러 가지 파일을 테스트해 보니 가끔 재생 못하는 파일이 있음을 발견했습니다. 원인을 분석해 보니 ID3v2 태그가 있는 파일을 인식하지 못하네요.
원래 MP3 파일은 곡명, 앨범명, 가수명 등의 추가 정보를 ID3 태그로 파일 끝 부분에 128 바이트 고정 길이로 가지는데 이 정도 길이로는 가사나 앨범 자켓 등을 저장하기에 택도 없는겁니다. 그래서 ID3v2 태그를 새로 만들었는데 이 녀석은 앞 부분에 붙입니다. 그래서 음성 데이터가 바로 나오지 않고 20년전에 만든 MCI는 이게 있으면 음성 파일로 인식하지 못합니다.
해결책은 앞부분의 태그를 건너뛰는건데 MCI가 메모리상의 스트림을 인식하지는 않고 무조건 파일 기반이기 때문에 임시 파일을 만들어야 합니다. 태그의 길이는 헤더의 6~9 바이트에 있되 상위 1비트는 버리고 7비트 4개를 모아 쓰는 synchsafe 방식으로 저장되어 있습니다. 게다가 리틀 엔디언이고 헤더 자체의 길인 10은 제외 되어 있어 길이를 알아내는 과정이 좀 복잡합니다. 다음 함수가 이 작업을 해 줍니다. 

// ID3v2 태그가 있는 MP3 파일은 태그를 건너뛰고 임시 파일을 생성한다. 
// 변환 여부를 리턴한다. 
bool StripID3Tag(LPCTSTR path)
{
    HANDLE hFile;
    DWORD dwRead, dwWritten;
    DWORD size;
    void* buf = NULL;

    // 원본 파일을 열고 버퍼로 읽어 들인다. 
    // 읽을 수 없다면 원본을 그대로 읽도록 무변환 상태로 리턴한다. 
    hFile = CreateFile(path, GENERIC_READ, 0, NULL,
        OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
    if (hFile == INVALID_HANDLE_VALUE) {
        return false;
    }
    size = GetFileSize(hFile, NULL);
    buf = malloc(size);
    ReadFile(hFile, buf, size, &dwRead, NULL);
    CloseHandle(hFile);

    int offset = 0;
    char* p = (char*)buf;

    // ID3로 시작하면 태그가 있는 것이다. 태그를 빼고 나머지 음성 데이터만으로 임시 파일을 만든다. 
    if (p[0] == 'I' && p[1] == 'D' && p[2] == '3') {
        // 태그의 길이는 6~9바이트에 있되 synchsafe 포맷으로 되어 있다.
        // 첫 비트는 모두 0이며 하위 7비트만 사용하는 리틀엔디언이다. 
        offset = p[6] * pow(2, 21) + p[7] * pow(2, 14) + p[8] * pow(2, 7) + p[9];
        // 태그의 길이에서 헤더 길이는 빠지므로 헤더 길이 10만큼 더해야 음성이 있는 위치가 된다.
        offset += 10;

        // 임시 파일을 생성한다. MCI가 메모리상의 음성 파일을 지원하지 않아 반드시 파일을 경유해야 한다.
        // 숨김 파일로 생성하되 프로그램 종료시 이 파일을 반드시 지워야 한다. 
        hFile = CreateFile(L"c:\\Temp\\temp.mp3", GENERIC_WRITE, 0, NULL,
            CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL | FILE_ATTRIBUTE_HIDDEN, NULL);
        WriteFile(hFile, p + offset, size - offset, &dwWritten, NULL);
        CloseHandle(hFile);
    }
    free(buf);

    // 변환 여부를 리턴한다. 
    if (offset == 0) {
        return false;
    } else {
        return true;
    }
}

코드 설명은 주석에 충분히 달아 두었습니다. 7비트씩 빼 내는거야 전혀 어려운 작업이 아닌데 비트 구조를 잘 모르는 요즘 세대들에게는 무척 어려운 코드가 될 거 같네요. 절대 경로의 임시 파일을 사용했는데 사용자들이 잘 안보는 위치나 아니면 메모리 맵 파일로 생성해서 사용하는 방법도 가능합니다. 여기서는 원리를 설명하는게 중요하니 임시 파일을 사용했습니다. 재생하는 부분에서는 태그를 먼저 걷어낸 파일을 재생합니다. 

    // ID3 태그를 제거했으면 변환한 파일을 재생하고 그렇지 않으면 원본을 재생한다. 
    TCHAR tPath[MAX_PATH];
    if (StripID3Tag(arMp3[mp_idx])) {
        lstrcpy(tPath, L"c:\\Temp\\temp.mp3");
    } else {
        lstrcpy(tPath, arMp3[mp_idx]);
    }

이 코드로 테스트해 본결과 제가 가진 MP3 파일은 대부분 잘 재생되었습니다. 그러나 ID3v2 태그도 버전에 따라 조금씩 다르다는 얘기도 있어 다른 문제가 더 있을 수도 있습니다. 그런 경우라도 웬만하면 해결할 수 있을 거 같습니다. 
FMOD나 BASS를 쓰면 쉽게 풀 수 있는 문제를 가급적 MCI만으로 해결하다 보니 복잡하네요. 꼭 돈이 없어서 그런게 아니라 추가 라이브러리 파일이 따라 붙는게 배포상 번거롭게 가급적이면 윈도우의 기본 기능만으로 완성하기 위해서입니다. MCI가 만들어진지 오래되었지만 몇 가지 문제만 해결하면 아직도 쓸만한 거 같습니다. 






 


로그인하셔야 댓글을 달 수 있습니다.