Konwerter cyfrowo-analogowy – odtwarzacz WAVE

Budując robota często chcielibyśmy, aby wydawał dźwięki. Możemy to zrealizować na kilka sposobów np. wykorzystując buzzer lub gotowy moduł do odtwarzania plików dźwiękowych. STM32 oferuje nam możliwość odtwarzania dźwięków za pomocą konwertera cyfrowo-analogowego. W poniższym artykule postaram się przybliżyć, jak do generowania dźwięków wykorzystać DAC w połączeniu z timerem i DMA. W ramach małego projektu stworzymy prosty odtwarzacz plików WAVE.

Budowa pliku WAVE

Plik WAV (lub WAVE), czyli Waveform Audio File Format, to format plików audio bazujący na formacie RIFF. Wykorzystuje bezstratną jakość dźwięku i jest podstawowym formatem stosowanym do przechowywania plików dźwiękowych na komputerach. Najczęściej wykorzystuje się nieskompresowany format PCM (Pulse Code Modulation), w którym kolejne próbki dźwięku są zapisane wprost w postaci liczb ze znakiem bez żadnego kodowania i kompresji. Ze względu na to, że do odczytania wartości próbek nie potrzebujemy żadnego kodeka, WAV jest najprostszym formatem plików audio.

Do implementacji odtwarzacza potrzebna nam będzie przede wszystkim informacja o budowie pliku WAVE. Możemy go podzielić na dwie części: nagłówek o długości 44 bajtów oraz dane. Nagłówek składa się z następujących pól:

Rozmiar (w Bajtach)NazwaOpis
4„RIFF”Początek nagłówka określający format RIFF
4fileSizeRozmiar pliku
4„WAVE”Typ pliku określający format WAV
4„fmt „Format chunk marker
416Rozmiar formatu danych
21Typ formatu (wartość 1 oznacza PCM)
2channelsLiczba kanałów
4sampleRateCzęstotliwość próbkowania
4bytesPerSecondIlość bajtów na sekundę
2bytesPerSampleIlość bajtów na próbkę
2bitsPerSampleIlość bitów na próbkę (rozdzielczość)
4„data”Znacznik początku danych
4dataSizeRozmiar danych w bajtach

Wzmacniacz audio PAM8403

Mikrokontroler na wyjściu z DAC generuje sygnał analogowy odpowiadający kolejnym próbkom dźwięku. Aby usłyszeć generowany dźwięk, musimy podłączyć do niego głośnik. Sam głośnik jednak nie wystarczy – sygnał z mikrokontrolera ma bardzo małą moc, a głośnik (nawet najmniejszy) potrzebuje odpowiedniego sygnału, aby wygenerować słyszalny dla człowieka dźwięk. Z tego powodu stosuje się wzmacniacze. Są różne rodzaje wzmacniaczy, o różnych mocach i przystosowane do pracy z głośnikami o różnej impedancji. Ja do wykonania odtwarzacza wykorzystam bardzo popularny, tani i dostępny w formie różnych modułów wzmacniacz PAM8403.

PAM8403 to dwukanałowy wzmacniacz klasy D o mocy 3 W na kanał. Na płytce PCB umieszczono wszystkie niezbędne elementy pasywne potrzebne do prawidłowej pracy układu. Na złączu wyprowadzone mamy piny wzmacniacza, w tym:

NazwaOpis
R+, R-Wyjście audio prawego kanału
L+, L-Wyjście audio lewego kanału
GNDMasa
+5VZasilanie 5 V
SWPrzełącznik mute
GNDMasa
LINWejście audio lewego kanału
GNDMasa
RINWejście audio prawego kanału

Do wyjścia audio podłączany jest głośnik. Układ zasilany jest napięciem 5 V, ale sterowanie za pomocą sygnałów 3,3 V nie powinno powodować problemów. Przełącznik mute pozwala nam na wyciszenie wzmacniacza w momencie, gdy dźwięk nie jest odtwarzany, aby ewentualne śmieci na sygnale DAC nie powodowały niepotrzebnych szumów na głośniku. Do wejścia audio podłączamy sygnał z konwertera DAC.

Konfiguracja mikrokontrolera

Konfiguracja mikrokontrolera do efektywnego odtwarzania dźwięków wymaga połączenia konwertera DAC z Timer-em, który będzie z odpowiednimi interwałami podawał kolejne próbki audio na wyjście analogowe. Na początek skonfigurujemy przetwornik. Wybierzemy sobie jedno wyjście (będziemy odtwarzać dźwięki mono) OUT1 w trybie „only to external pin”. Następnie przechodzimy do konfiguracji kanału. Wybieramy wyjście z buforowaniem oraz przełączanie jako „Timer 6 Trigger Out Event”, aby Timer 6 powodował przełączanie kolejnych próbek. User Trimming pozostawiamy jako ustawienia fabryczne. Nie będziemy wykorzystywali trybu Sample and Hold.

Aby nie obciążać mikrokontrolera podczas przesyłania kolejnych próbek do rejestru przetwornika, wykorzystamy do tego celu DMA. DAC jest połączony z DMA1 na kanale 3. Wybieramy kierunek „Memory to Peripheral”, tryb „Normal” (nie chcemy, aby dźwięk odtwarzał się w pętli) oraz szerokość danych jako Half Word. Dane będziemy inkrementowali po stronie pamięci.

Teraz możemy skonfigurować układ czasowy. Prescaler ustawiamy jako 0. Timer będzie decydował o tym, z jaką częstotliwością próbkowania będzie odtwarzany nasz dźwięk. Informacja o Sample Rate umieszczona jest w nagłówku pliku WAV, dlatego dopiero po odczytaniu tej wartości prawidłowo skonfigurujemy wartość „Counter Period”. Takie rozwiązanie pozwoli nam na odtwarzanie dźwięków o dowolnej częstotliwości próbkowania bez potrzeby zmiany kodu. Teraz musimy skonfigurować tylko „Trigger Event Selection” jako „Update Event”, czyli generowanie sygnału przełączającego po osiągnięciu wartości Counter Period.

Wyjście DAC CH1 będziemy mieli dostępne na pinie PA4. Konfiguracja pinów mikrokontrolera wygląda następująco.

Mając tak skonfigurowane układy peryferyjne mikrokontrolera możemy wygenerować projekt.

Implementacja

Pierwszym elementem jest właściwe przygotowanie struktury przechowującej nagłówek pliku WAV. Odpowiednie jej zorganizowanie pozwoli nam w łatwy sposób parsować dane bez potrzeby pisania niepotrzebnie dużej ilości kodu. W strukturze zapisałem zatem wszystkie elementy tablicy zgodnie z dokumentacją nagłówka. Uwzględniłem ilość bajtów, które zajmuje każdy z elementów. Ważną kwestią jest zastosowanie atrybutu „packed” przy pisaniu struktury. Dzięki temu mamy pewność, że dane będą ułożone bezpośrednio po sobie, a nie z wyrównaniem do uint32.

struct __attribute__((packed)) file_header
{
	uint32_t riff_hdr;
	uint32_t file_size;
	uint32_t wave_id;
	uint32_t chunk_marker;
	uint32_t file_format_size;
	uint16_t format_type;
	uint16_t channels;
	uint32_t sample_rate;
	uint32_t bytes_per_sec;
	uint16_t bytes_per_sample;
	uint16_t bits_per_sample;
	uint32_t data_id;
	uint32_t data_size;
};

Aby parsowanie nagłówka odbywa się „automatycznie”, wykorzystam unię. Dzięki temu samo podanie danych z buforem jako tablicę bytes spowoduje, że elementy nagłówka „ułożą” się nam same w odpowiednich elementach struktury.

union wave_file_header
{
    uint8_t bytes[WAVE_FILE_HEADER_SIZE];
    struct file_header wave_file_hdr;
};

W pliku „wave_file.h” dodałem jeszcze kilka definicji stałych z długością nagłówka oraz stałymi elementami nagłówka charakterystycznymi dla plików WAVE, aby je zweryfikować przy odtwarzaniu.

#define WAVE_FILE_HEADER_SIZE	44

#define RIFF_HDR_CONST	0x46464952
#define WAVE_ID_CONST	0x45564157
#define FMT_CONST	0x20746D66
#define CHANNEL_MONO	1

W pliku „wave_player.h” stworzyłem strukturę odpowiadającą za przechowywanie danych o procesie odtwarzania. Jest tam wskaźnik do aktualnej próbki dźwięku, bufor na dane, licznik danych oraz wskaźniki do timera i przetwornika DAC. Stałe informują nas o długości bufora z próbkami.

#define FIRST_HALF_OF_BUFFER		1
#define SECOND_HALF_OF_BUFFER		2

#define AUDIO_BUFFER_SIZE		512
#define BYTES_IN_AUDIO_BUFFER_SIZE	AUDIO_BUFFER_SIZE*2

struct wave_player_s
{
	TIM_HandleTypeDef *htim;
	DAC_HandleTypeDef *hdac;

    uint8_t *data_pointer;
	uint16_t buffer[AUDIO_BUFFER_SIZE];
	volatile uint32_t byte_counter;
	union wave_file_header file_hdr;
};

W pliku „wave_player.c” na wstępie tworzymy funkcję inicjalizacyjną, do której przekażemy wskaźnik na używany timer i konwerter DAC.

void wave_player_init(TIM_HandleTypeDef *_htim, DAC_HandleTypeDef *_hdac)
{
	wave_player.htim = _htim;
	wave_player.hdac = _hdac;
}

Następnie tworzymy funkcję rozpoczynającą proces odtwarzania. W niej wywołujemy odczyt i sprawdzenie poprawności nagłówka (czy rzeczywiście mamy do czynienia z plikiem WAVE), przygotowujemy pierwszy bufor z danymi i startujemy odtwarzanie za pomocą timera i DMA. Jako argument funkcja przyjmuje wskaźnik do danych z plikiem audio. Pliki takie zajmują sporo miejsca i przechowywanie ich w pamięci mikrokontrolera jest niemożliwe. Na potrzeby tego artykułu umieściłem fragment pliku audio w pliku „wave_file.c” jako tablicę const uint8_t audio_file[], jednak w przypadku dłuższych plików dane trzeba przechowywać na zewnętrznym nośniku – karcie SD, zewnętrznej pamięci Flash lub pendrive. Aby nie mieszać tematów, zdecydowałem się na odtwarzanie próbki pliku bezpośrednio jako tablicy umieszczonej we flash-u mikrokontrolera (gwarantuje to nam modyfikator const – w przeciwnym wypadku tablica umieszczona byłaby w pamięci RAM, a jej mamy jeszcze mniej).

Warto tutaj zwrócić uwagę na jeszcze jedną rzecz. Przetwornik DAC w STM32 jest 12-bitowy, a my mamy próbki 16-bitowe. Musimy zatem przekształcić 16-bitowe dane na dane 12-bitowe. Możemy to zrobić na dwa sposoby: przesunąć każdą próbkę o 4 bity w prawo (interesują nas bity od 4 do 16), albo wykorzystać wyrównanie danych DAC do lewej. Druga metoda będzie wygodniejsza i mniej obciążająca dla mikrokontrolera.

void wave_player_start(uint8_t *file)
{
	int8_t status;

	status = wave_player_read_header(file);

	if(ERROR == status)
	{
		return;
	}

	wave_player_set_timer_arr(wave_player.file_hdr.wave_file_hdr.sample_rate);

	wave_player_prepare_first_buffer();

	HAL_TIM_Base_Start(wave_player.htim);
	HAL_DAC_Start_DMA(wave_player.hdac, DAC_CHANNEL_1, (uint32_t *)wave_player.buffer, AUDIO_BUFFER_SIZE, DAC_ALIGN_12B_L);
}

Za analizę nagłówka pliku odpowiada funkcja wave_player_read_header. W pętli wpisujemy do tablicy bajty nagłówka, a zastosowanie unii powoduje, że elementy struktury same się uzupełniają kolejnymi bajtami. Następnie sprawdzamy, czy podstawowe elementy tablicy zgadzają się z formatem WAVE. Jak możemy zauważyć, nasza aplikacją będzie odtwarzała tylko pliki Mono, ale obsługa plików Stereo wymagałaby jedynie dodania drugiego kanału DAC i przesyłania próbek naprzemiennie do kanału 1 i 2..

int8_t wave_player_read_header(uint8_t *file)
{
	uint32_t i;

	wave_player.data_pointer = file;

	for(i = 0; i < WAVE_FILE_HEADER_SIZE; i++)
	{
		wave_player.file_hdr.bytes[i] = *(wave_player.data_pointer+i);
	}

	if(RIFF_HDR_CONST != wave_player.file_hdr.wave_file_hdr.riff_hdr)
	{
		return ERROR;
	}

	if(WAVE_ID_CONST != wave_player.file_hdr.wave_file_hdr.wave_id)
	{
		return ERROR;
	}

	if(FMT_CONST != wave_player.file_hdr.wave_file_hdr.chunk_marker)
	{
		return ERROR;
	}

	if(CHANNEL_MONO != wave_player.file_hdr.wave_file_hdr.channels)
	{
		return ERROR;
	}

	wave_player.byte_counter = WAVE_FILE_HEADER_SIZE;

	return SUCCESS;
}

Mając odczytany nagłówek, ustawiamy parametry timera zgodnie z odczytaną częstotliwością próbkowania.

void wave_player_set_timer_arr(uint32_t sample_rate)
{
	uint32_t arr;

	arr = HAL_RCC_GetPCLK1Freq() / sample_rate;

	__HAL_TIM_SET_AUTORELOAD(wave_player.htim, arr - 1);
}

Następnie przygotowujemy dane w buforze.

void wave_player_prepare_first_buffer(void)
{
	int32_t i;
	int16_t audio_sample;

	for(i = 0; i < AUDIO_BUFFER_SIZE; i++)
	{
		audio_sample = (*(wave_player.data_pointer+(wave_player.byte_counter+1)) << 8) | *(wave_player.data_pointer+wave_player.byte_counter);

		wave_player.buffer[i] = (audio_sample + 32768);

		wave_player.byte_counter += 2;
	}
}

Po wystartowaniu DAC z DMA, będziemy otrzymywali dwa przerwania – po przesłaniu połowy bufora i po wysłaniu całego bufora. Dzięki temu będziemy wypełniać bufor po połowie, zachowując ciągłość odtwarzania. Gdy będzie się odtwarzała pierwsza połowa bufora, my przygotujemy dane dla drugiej. Gdy będziemy odtwarzali drugą połowę, przygotujemy dane do pierwszej. W ten sposób odtworzymy cały plik audio.

Fragmentem, na który chciałbym zwrócić uwagę, jest umieszczenie próbki w buforze. Dodaję tutaj wartość 32768, ponieważ wartości w pliku WAVE są ze znakiem (signed), a my potrzebujemy przekazać do DAC dane bez znaku (unsigned). Dodając 32768 przesuwamy dolny próg wartości dźwięku z -32768 na 0, a 0 na wartość 32768, zachowując wszystkie informacje o dźwięku.

void wave_player_prepare_half_buffer(uint8_t half_number)
{
	int32_t i;
	int16_t audio_sample;

	if(FIRST_HALF_OF_BUFFER == half_number)
	{
		for(i = 0; i < AUDIO_BUFFER_SIZE/2; i++)
		{
			audio_sample = (*(wave_player.data_pointer+(wave_player.byte_counter+1)) << 8) | *(wave_player.data_pointer+wave_player.byte_counter);

			wave_player.buffer[i] = (audio_sample + 32768);

			wave_player.byte_counter += 2;

			if(wave_player.byte_counter >= wave_player.file_hdr.wave_file_hdr.data_size)
			{
				HAL_DAC_Stop_DMA(wave_player.hdac, DAC_CHANNEL_1);
				return;
			}
		}
	}
	else if(SECOND_HALF_OF_BUFFER == half_number)
	{
		HAL_DAC_Start_DMA(wave_player.hdac, DAC_CHANNEL_1, (uint32_t *)wave_player.buffer, AUDIO_BUFFER_SIZE, DAC_ALIGN_12B_L);

		for(i = AUDIO_BUFFER_SIZE/2; i < AUDIO_BUFFER_SIZE; i++)
		{
			audio_sample = (*(wave_player.data_pointer+(wave_player.byte_counter+1)) << 8) | *(wave_player.data_pointer+wave_player.byte_counter);

			wave_player.buffer[i] = (audio_sample + 32768);

			wave_player.byte_counter += 2;

			if(wave_player.byte_counter >= wave_player.file_hdr.wave_file_hdr.data_size)
			{
				HAL_DAC_Stop_DMA(wave_player.hdac, DAC_CHANNEL_1);
				return;
			}
		}
	}
}

Obsługę przerwań umieściłem w pliku main.c.

/* USER CODE BEGIN 4 */
void HAL_DAC_ConvCpltCallbackCh1(DAC_HandleTypeDef *hdac)
{
	wave_player_prepare_half_buffer(SECOND_HALF_OF_BUFFER);
}

void HAL_DAC_ConvHalfCpltCallbackCh1(DAC_HandleTypeDef *hdac)
{
	wave_player_prepare_half_buffer(FIRST_HALF_OF_BUFFER);
}
/* USER CODE END 4 */

Teraz pozostało nam tylko wywołać odtwarzanie w funkcji main (przed pętlą while(1)).

  /* USER CODE BEGIN 2 */
  wave_player_init(&htim6, &hdac1);
  wave_player_start(audio_file);
  /* USER CODE END 2 */

Na koniec musimy jeszcze prawidłowo podłączyć wzmacniacz do mikrokontrolera. Potrzebujemy 4 pinów: VCC do 5 V, GND, RN do PA4 oraz SW do 3,3 V (aby wzmacniacz był włączony – w docelowym projekcie można sterować tym pinem jako GPIO i wyłączać po odtworzeniu dźwięku, aby uniknąć niepotrzebnych szumów i zakłóceń). Sposób podłączenia przedstawia poniższy schemat.

Podsumowanie

W materiale przedstawiłem, w jaki sposób zrealizować odtwarzanie plików WAVE przy pomocy przetwornika DAC wbudowanego w mikrokontroler STM32. Taka metoda sprawdzi się w prostszych projektach, gdzie jakość 12-bitów będzie dla nas wystarczająca. Jeżeli jednak nasza aplikacja wymaga 16 lub 32-bitowych dźwięków, musimy sięgnąć po zewnętrzny przetwornik DAC i interfejs I2S. Takim sposobem odtwarzania zajmiemy się w kolejnym artykule.

Do pobrania

Repozytorium GitHub

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *