Software Development

Niskopoziomowa obsługa audio w Windows

Luty 24, 2016 0
Podziel się:

Przedmiotem artykułu będzie obsługa dźwięku w systemie Windows poprzez natywne API systemu. Na co dzień mamy do dyspozycji różnego rodzaju frameworki i biblioteki, które dużo pracy wykonują za nas. Do obsługi audio powstało już wiele takowych – chociażby BASS (C++), NAudio (.NET) i wiele innych… Dzisiaj jednak spróbujemy odwołać się do karty dźwiękowej niskopoziomowo – stworzymy prosty program, który pozwoli na odtworzenie pewnych danych audio.
Zacznijmy od dołączenia pliku nagłówkowego “mmsystem.h” oraz poinformowania linkera o użyciu pliku “winmm.lib”, czyli:

#include "mmsystem.h"
#pragma comment(lib, "winmm.lib"). //oczywiście można również zrobić to w sposób "klasyczny" - w opcjach linkera.

Zanim przejdziemy do meritum, czyli do samego odtwarzania, zastanówmy się jakie dane chcemy odtwarzać. Mamy kilka możliwości – odtworzenie jakiegoś pliku WAV, strumienia danych z sieci, etc… możemy również sami wygenerować sobie dźwięk który później odtworzymy. Skorzystamy z ostatniej opcji – przygotujemy prosty algorytm za pomocą którego wygenerowana zostanie tablica bajtów będąca źródłem danych wysyłanych do karty dźwiękowej.
Dla uproszczenia przyjmiemy, że dane dźwiękowe zapisane będą w prostym formacie:

  • 1 kanał (czyli mono)
  • 8 bitów na próbkę
  • 8000 próbek na sekundę.

Czyli na każdą sekundę należy wygenerować 8000 próbek 1 bajtowych.
Algorytm przedstawia się następująco:

char buffer[8000 * 10];	//bufor na próbki o wielkości 10 sekund * 8000 próbek 
for (DWORD t = 0; t < sizeof(buffer); ++t) 
{ 
   buffer[t] = (t * 5 & t >> 7) | (t * 3 & t >> 10);
} 

Oczywiście jest to tylko przykład algorytmu tego rodzaju. Zachęcam czytelnika do eksperymentowania i zapoznania się z ciekawymi publikacjami na ten temat, gdyż można osiągnąć bardzo ciekawe efekty. Dla zainteresowanych polecam link:
http://countercomplex.blogspot.com/2011/10/algorithmic-symphonies-from-one-line-of.html

Teraz przejdę do właściwej części artykułu, czyli odtwarzania naszego dźwieku.
Na początek trzeba wypełnić strukturę opisującą format audio. Ta struktura to WAVEFORMATEX a przedstawia się ona następująco:

typedef struct tWAVEFORMATEX
{
    WORD        wFormatTag;         /* identyfikator formatu */
    WORD        nChannels;          /* liczba kanałów (mono, stereo itp...) */
    DWORD       nSamplesPerSec;     /* ilość próbek na sekundę */
    DWORD       nAvgBytesPerSec;    /* wymagana szybkość transferu dancyh. Dla formatu PCM, wynosi nSamplesPerSec × nBlockAlign. */
    WORD        nBlockAlign;        /* minimalna wielkość paczki danych. Dla formatu PCM, wynosi (nChannels × wBitsPerSample) / 8. */
    WORD        wBitsPerSample;     /* ilość bitów dla jednej próbki */
    WORD        cbSize;             /* ilość (w bajtach) danych dodatkowych (dla innych formatów) */
                                    /* dane dodatkowe (jeśli są) */
} WAVEFORMATEX

Tutaj powyższa struktura ma postać:

WAVEFORMATEX wfx;	
wfx.wFormatTag = WAVE_FORMAT_PCM; //zdefiniowany w WINAPI identyfikator formatu PCM
wfx.wBitsPerSample =8;
wfx.nSamplesPerSec = 8000;
wfx.nChannels = 1;
wfx.nBlockAlign = 1;
wfx.nAvgBytesPerSec = 8000;
wfx.cbSize = 0;

Następną strukturą którą musimy wypełnić jest WAVEHDR:

typedef struct wavehdr_tag 
{
    LPSTR       lpData;                 /* bufor z danymi audio */
    DWORD       dwBufferLength;         /* długość bufora audio */
    DWORD       dwBytesRecorded;        /* ilość nagranych danych (tylko do użytku przy nagrywaniu) */
    DWORD_PTR   dwUser;                 /* inne dane użytkownika */
    DWORD       dwFlags;                /* flagi stanu, pętli */
    DWORD       dwLoops;                /* licznik pętli */
    struct wavehdr_tag FAR *lpNext;     /* zarezerwowane */
    DWORD_PTR   reserved;               /* zarezerwowane */
} WAVEHDR

W tym przypadku będziemy używać tylko dwóch pól i będzie miała ona postać:

WAVEHDR header;
memset(&header, 0, sizeof(WAVEHDR)); //zerujemy wszystkie pola struktury
header.lpData = buffer;
header.dwBufferLength = sizeof(buffer);

Dane wejściowe są już przygotowane, więc nadeszła pora na wydobycie dźwięku z naszego komputera.
Na początek należy sprawdzić czy można w ogóle odtworzyć dane w formacie zdefiniowanym w strukturze WAVEFORMATEX. W tym celu wykorzystamy funkcję WaveOutOpen, która przedstawia się następująco.
(dokładny opis w MSDN: https://msdn.microsoft.com/en-us/library/windows/desktop/dd743866(v=vs.85).aspx)

MMRESULT waveOutOpen(
   LPHWAVEOUT     phwo, //wskaźnik do zmiennej typu HWAVEOUT w której przechowywany będzie uchwyt do urządzenia audio
   UINT_PTR       uDeviceID, //identyfikator urządzenia audio w systemie. Jeśli podamy WAVE_MAPPER (zdefiniowany w WINAPI), system sam wybierze odpowiednie urządzenie
   LPWAVEFORMATEX pwfx, //wskaźnik do struktury WAVEFORMATEX definiującej nasz format audio
   DWORD_PTR      dwCallback, //wskaźnik do funkcji callback, uchwyt do okna, identyfikator wątku lub NULL (szczegóły na MSDN)
   DWORD_PTR      dwCallbackInstance, //dane które zostaną przekazane do funkcji callback
   DWORD          fdwOpen //Flagi definiujące tryb wywoływania tej funkcji (szczegóły na MSDN)
);

Powyższą funkcję wywołujemy z parametrami:

waveOutOpen(NULL, WAVE_MAPPER, &wfx, NULL, 0, WAVE_FORMAT_QUERY);

Jeśli wywołanie tej funkcji zwróci MMSYSERR_NOERROR (czyli 0), można próbować otworzyć urządzenie audio. W tym celu wykorzystujemy tą samą funkcję, tylko z nieco innymi parametrami:

HWAVEOUT hWaveOut;
waveOutOpen(&hWaveOut, WAVE_MAPPER, &wfx, (DWORD_PTR)waveOutProc, 0, CALLBACK_FUNCTION);

Funkcja callback do której wskaźnik przekazujemy, według dokumentacji przedstawia się następująco:

void CALLBACK waveOutProc(
   HWAVEOUT  hwo, //uchwyt do urządzenia audio
   UINT      uMsg, //wiadomość przekazana do funkcji. Może przyjmować wartości WOM_OPEN, WOM_DONE, WOM_CLOSE 
   DWORD_PTR dwInstance, //dane przekazane w funkcji WaveOutOpen
   DWORD_PTR dwParam1, // parametr wiadomości (zależy od typu wiadomości uMsg)
   DWORD_PTR dwParam2 // parametr wiadomości (zależy od typu wiadomości uMsg)
);

W naszym przypadku, ma ona postać:

void CALLBACK waveOutProc(HWAVEOUT  hwo,UINT  uMsg,	DWORD_PTR dwInstance,	DWORD_PTR dwParam1,	DWORD_PTR dwParam2)
{

	switch (uMsg)
	{
	case WOM_DONE: 		
		SetEvent(onPlaybackEnd);
		break;
	case WOM_CLOSE:		
		break;
	case WOM_OPEN:
		break;
	}
}

Funkcję tą bardziej szczegółowo omówię w dalszej części artykułu.
Skoro mamy już zdefiniowaną funkcję callback, oraz możemy otworzyć urządzenie audio, nic nie stoi na przeszkodzie aby wysłać do niego nasze dane.
W tym celu najpierw wywołujemy funkcję waveOutPrepareHeader która ma następującą deklarację:

MMRESULT waveOutPrepareHeader(
   HWAVEOUT  hwo, //uchwyt do urządzenia audio
   LPWAVEHDR pwh, //wskaźnik do struktury WAVEHDR opisującej blok danych audio
   UINT      cbwh //wielkość (w bajtach) struktury WAVEHDR
);

W naszym przypadku mamy:

waveOutPrepareHeader(hWaveOut, &header, sizeof(WAVEHDR));	

Jeśli funkcja nie zwróci nam błędu, pozostaje już tylko wywołać funkcję która wysyła dane audio do karty dźwiękowej, czyli funkcję waveOutWrite:

MMRESULT waveOutWrite(
   HWAVEOUT  hwo, // uchwyt do urządzenia audio
   LPWAVEHDR pwh, // wskaźnik do struktury WAVEHDR opisującej blok danych audio
   UINT      cbwh //wielkość (w bajtach) struktury WAVEHDR
);

U nas wygląda to następująco:

waveOutWrite(hWaveOut, &header, sizeof(WAVEHDR));

Jeśli wszystko przebiegło pomyślnie, powinniśmy usłyszeć efekty naszej pracy. Pozostało już tylko po sobie posprzątać, czyli pozamykać odpowiednie uchwyty itp…
Zanim to zrobimy, poświęcę chwilę na omówienie funkcji callback która pojawiła się wcześniej.
Funkcja ta posiada parametr uMsg, który może przyjąć jedną z trzech wartości:

WOM_CLOSE – w przypadku kiedy urządzenie jest zamykane przy użyciu funkcji waveOutClose
WOM_DONE – w przypadku kiedy odtwarzanie bufora zostanie zakończone
WOM_OPEN – w przypadku kiedy urządzenie jest otwierane przy pomocy funkcji waveOutOpen

Interesuje nas przypadek, kiedy przyjdzie wiadomość WOM_DONE – informacja o zakończeniu odtwarzania. Jak już wcześniej wspomniałem, w tym momencie należy pozamykać użyte zasoby (bądź przesłać kolejne dane – ale o tym później). Tutaj pojawia się pewien problem, gdyż zgodnie z dokumentacją – wywołanie z poziomu funkcji callback, jakichkolwiek funkcji poza: EnterCriticalSection, LeaveCriticalSection, midiOutLongMsg, midiOutShortMsg, OutputDebugString, PostMessage, PostThreadMessage, SetEvent, timeGetSystemTime, timeGetTime, timeKillEvent, timeSetEvent może spowodować zawieszenie aplikacji (deadlock).
Aby uniknąć kłopotów z tym związanych, posłużymy się zdarzeniem systemowym.
Na początku programu należy utworzyć uchwyt do zdarzenia (jako zmienna globalna):

HANDLE onPlaybackEnd; //deklarujemy jako zmienną globalną
//...
onPlaybackEnd = CreateEvent(NULL, false, false, NULL); //uchwyt musimy utworzyć przed wywołaniem funkcji waveOutWrite.

Następnie po wywołaniu funkcji waveOutWrite, wywołujemy funkcję:

WaitForSingleObject(onPlaybackEnd, INFINITE); 

W tym momencie program czeka na zasygnalizowanie zdarzenia onPlaybackEnd, aby można było rozpocząć zwalnianie użytych zasobów. Teraz już widać co się dzieje w funkcji callback – w momencie otrzymania wiadomości o zakończeniu odtwarzania (WOM_DONE) ustawiane jest zdarzenie onPlaybackEnd, czyli:

SetEvent(onPlaybackEnd);

Teraz program może przystąpić do zwalniania zasobów, czyli wywołania funkcji waveoutUnprepareHeader, oraz waveOutClose. Mają one następujące deklaracje:

MMRESULT waveOutUnprepareHeader(
   HWAVEOUT  hwo, //uchwyt do urządzenia audio
   LPWAVEHDR pwh, //wskaźnik do struktury WAVEHDR opisującej blok danych audio
   UINT      cbwh //wielkość (w bajtach) struktury WAVEHDR
);

MMRESULT waveOutClose(
   HWAVEOUT hwo //uchwyt do urządzenia audio
);

Wywołujemy je w następujący sposób:

waveOutUnprepareHeader(hWaveOut, &header, sizeof(WAVEHDR));

Jeśli nie otrzymamy błędu, wywołujemy:

waveOutClose(hWaveOut);

Tutaj również należy sprawdzić, czy funkcja nie zwróciła wartości oznaczającej błąd. Teraz już tylko wystarczy zamknąć uchwyt do zdarzenia onPlaybackEnd:

CloseHandle(onPlaybackEnd);

Mamy już opanowany najprostszy przypadek w którym wysłaliśmy jednorazowo do karty dźwiękowej wszystkie dane. Nie uwzględniliśmy jednak jednej bardzo ważnej kwestii – danych do odtworzenia może być bardzo dużo (np. musimy odtworzyć plik WAV o wielkości kilkuset megabajtów). Nie jest dobrą praktyką wysłanie tak wielkiej porcji danych za pomocą funkcji waveOutWrite.
Pierwszą rzeczą która przychodzi na myśl jest cykliczne wysyłanie danych w częściach, np. po 512 bajtów, w skrócie:
– przygotowujemy strukturę WAVEHDR
– wywołujemy waveOutPrepareHeader
– wywołujemy waveOutWrite
– czekamy na callBack (WOM_DONE)
– wywołujemy waveOutUnprepareHeader
– aktualizujemy strukturę WAVEHDR przestawiając wskaźnik do danych o 512 bajtów
– wywołujemy waveOutPrepareHeader
– wywołujemy waveOutWrite…
… i tak aż do zakończenia danych wejściowych. Pomysł wydaje się dobry, jednak odtwarzając w ten sposób pojawią się krótkie, aczkolwiek słyszalne przerwy w pomiędzy każdymi 512 bajtami danych. Jak temu zaradzić? Z pomocą przychodzi algorytm zwany “double buffering”.
Polega on na tym, że na początku przygotowujemy nie jeden, a dwa (lub więcej) bufory danych, następnie wysyłamy do karty dźwiękowej dane z obydwu buforów. Potem czekamy na wiadomość o zakończeniu odtwarzania danych z pierwszego z nich. W tym momencie (podczas gdy dane z drugiego bufora są ciągle odtwarzane) przygotowujemy dane dla pierwszego bufora (aktualizujemy strukturę WAVEHDR) i wysyłamy dane do karty dźwiękowej. Gdy dostaniemy informację o zakończeniu przetwarzania drugiego bufora, aktualizujemy go (w tym czasie odtwarzana jest kolejna porcja z bufora pierwszego) i wysyłamy do urządzenia audio. Powtarzamy cały cykl aż do wyczerpania naszych danych wejściowych. W ten sposób wyeliminowaliśmy przerwy pomiędzy odtwarzaniem kolejnych porcji danych, ponieważ karta dźwiękowa cały czas ma dane do odtworzenia i nie musi czekać na dostarczenie nowych danych. Analogiczna metoda znajduje również zastosowanie w wyświetlaniu grafiki na ekranie monitora – zapobiega “migotaniu” obrazu.

Tak w dużym skrócie wygląda odtwarzanie danych audio w przy użyciu WINAPI. Oczywiście nie omówiłem tutaj wszystkich aspektów i funkcji API związanych z tym zagadnieniem. Zachęcam czytelnika do samodzielnego eksperymentowania z tym tematem. Oczywiście istnieją inne – prostsze interfejsy do obsługi multimediów (np. MCI), oraz rozmaite biblioteki. Warto jednak wiedzieć co kryje się “pod maską” tych bibliotek.

Poniżej listing programu z podwójnym buforowaniem:

#include "stdafx.h"
#include "windows.h"
#include "stdio.h"
#include "mmsystem.h"

#pragma comment(lib, "winmm.lib")

#define BUFFER_LENGTH 8000*10 //10 sekund * 8000 próbek
#define AUDIO_BUFFER_LENGTH 512
#define BUFFERS_COUNT 3

void CALLBACK waveOutProc(HWAVEOUT hwo, UINT uMsg, DWORD_PTR dwInstance, DWORD_PTR dwParam1, DWORD_PTR dwParam2);
void PrepareNextBuffer(HWAVEOUT *hWaveOut, WAVEHDR* hdr, char* buffer);
DWORD CalculateReadLength();
HANDLE onPlaybackEnd;
UINT position = 0;
UINT currentBuffer = 0;

int _tmain(int argc, _TCHAR* argv[])
{
	onPlaybackEnd = CreateEvent(NULL, false, false, NULL);
	if (onPlaybackEnd == NULL)
	{
		printf("ERROR - CreateEvent");
		return 0;
	}

	HWAVEOUT hWaveOut = 0;
	char buffer[BUFFER_LENGTH];
	for (DWORD t = 0; t < sizeof(buffer); ++t)
	{
		buffer[t] = (t * 5 & t >> 7) | (t * 3 & t >> 10);
	}

	WAVEFORMATEX wfx;
	memset(&wfx, 0, sizeof(WAVEFORMATEX));
	wfx.wFormatTag = WAVE_FORMAT_PCM;
	wfx.wBitsPerSample = 8;
	wfx.nSamplesPerSec = 8000;
	wfx.nChannels = 1;
	wfx.nBlockAlign = 1;
	wfx.nAvgBytesPerSec = 8000;
	wfx.cbSize = 0;


	WAVEHDR* headers = new WAVEHDR[BUFFERS_COUNT];
	for (int i = 0; i < BUFFERS_COUNT; i++)
	{
		memset(headers + i, 0, sizeof(WAVEHDR));
		(headers + i)->lpData = buffer + (position*AUDIO_BUFFER_LENGTH);
		(headers + i)->dwBufferLength = CalculateReadLength();
		position++;
	}
	
	if (waveOutOpen(NULL, WAVE_MAPPER, &wfx, NULL, 0, WAVE_FORMAT_QUERY) != MMSYSERR_NOERROR)
	{
		printf("ERROR - waveOutOpen - format query");
		CloseHandle(onPlaybackEnd);
		delete[] headers;
		return 0;
	}

	if (waveOutOpen(&hWaveOut, WAVE_MAPPER, &wfx, (DWORD_PTR)waveOutProc, 0, CALLBACK_FUNCTION) != MMSYSERR_NOERROR)
	{
		printf("ERROR - waveOutOpen");
		CloseHandle(onPlaybackEnd);
		delete[] headers;

		return 0;
	}

	for (int i = 0; i < BUFFERS_COUNT; i++)
	{
		if (waveOutPrepareHeader(hWaveOut, headers + i, sizeof(WAVEHDR)) != MMSYSERR_NOERROR)
		{
			printf("ERROR - waveOutPrepareHeader");
			CloseHandle(onPlaybackEnd);
			delete[] headers;
			return 0;
		}

	}

	for (int i = 0; i < BUFFERS_COUNT; i++)
	{
		if (waveOutWrite(hWaveOut, headers + i, sizeof(WAVEHDR)) != MMSYSERR_NOERROR)
		{
			printf("ERROR - WaveOutWrite");
			CloseHandle(onPlaybackEnd);
			delete[] headers;
			return 0;
		}
	}


	while (position*AUDIO_BUFFER_LENGTH < BUFFER_LENGTH)
	{
		WaitForSingleObject(onPlaybackEnd, INFINITE);
		printf("Finished buffer: %i\n", currentBuffer);
				
		if (waveOutUnprepareHeader(hWaveOut, headers + ((currentBuffer == 0) ? (BUFFERS_COUNT - 1) : (currentBuffer - 1)), sizeof(WAVEHDR)) != MMSYSERR_NOERROR)
		{
			printf("ERROR - waveOutUnprepareHeader");
		}
		PrepareNextBuffer(&hWaveOut, headers + ((currentBuffer == 0) ? (BUFFERS_COUNT - 1) : (currentBuffer - 1)), buffer);	
	}
	
	//czekamy na zakończenie wszystkich buforów
	for (int i = 0; i < BUFFERS_COUNT; i++)
	{
		WaitForSingleObject(onPlaybackEnd, INFINITE);
	}
	
	for (int i = 0; i < BUFFERS_COUNT; i++)
	{
		if (waveOutUnprepareHeader(hWaveOut, headers + i, sizeof(WAVEHDR)) != MMSYSERR_NOERROR)
		{
			printf("ERROR - waveOutUnprepareHeader");
		}
	}
	
	if (waveOutClose(hWaveOut) != MMSYSERR_NOERROR)
	{
		printf("ERROR - waveOutClose");
	}

	CloseHandle(onPlaybackEnd);
	delete[] headers;	
	return 0;
}

void PrepareNextBuffer(HWAVEOUT *hWaveOut, WAVEHDR* hdr, char* buffer)
{	
	memset(hdr, 0, sizeof(WAVEHDR));
	hdr->lpData = buffer + (position*AUDIO_BUFFER_LENGTH);
	hdr->dwBufferLength = CalculateReadLength();
	if (waveOutPrepareHeader(*hWaveOut, hdr, sizeof(WAVEHDR)) != MMSYSERR_NOERROR)
	{
		printf("ERROR - waveotPrepareHeader");		
	}
	if (waveOutWrite(*hWaveOut, hdr, sizeof(WAVEHDR)) != MMSYSERR_NOERROR)
	{
		printf("ERROR - waveoutWrite");		
	}
	printf("Position: %i\n", position*AUDIO_BUFFER_LENGTH + CalculateReadLength());
	position++;

}

DWORD CalculateReadLength()
{
	if ((position + 1)*AUDIO_BUFFER_LENGTH < BUFFER_LENGTH)
	{
		return AUDIO_BUFFER_LENGTH;
	}
	else
	{
		return BUFFER_LENGTH - (position*AUDIO_BUFFER_LENGTH);
	}
}

void CALLBACK waveOutProc(HWAVEOUT  hwo, UINT  uMsg, DWORD_PTR dwInstance, DWORD_PTR dwParam1, DWORD_PTR dwParam2)
{
	switch (uMsg)
	{
	case WOM_DONE:
		if (currentBuffer != BUFFERS_COUNT - 1)
			currentBuffer++;
		else
			currentBuffer = 0;
		SetEvent(onPlaybackEnd);
		break;
	case WOM_CLOSE:
		break;
	case WOM_OPEN:
		break;
	}
}


Oceń ten post
Tagi: , , ,
Paweł Ciuraj
Autor: Paweł Ciuraj
Starszy inżynier ds. oprogramowania w SII.

Imię i nazwisko (wymagane)

Adres email (wymagane)

Temat

Treść wiadomości

Zostaw komentarz