Interfejs I2S – odtwarzacz WAVE

Mikrokontrolery STM32 mają wbudowany konwerter DAC o rozdzielczości 12-bitów. Jeżeli jednak nasza aplikacja wymaga 16 lub 32-bitowych dźwięków, musimy sięgnąć po zewnętrzny przetwornik cyfrowo-analogowy i interfejs I2S. Jak skonfigurować i przesyłać dane za pomocą tego popularnego interfejsu audio, przedstawię w poniższym artykule.

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

MAX98357A – konwerter DAC I2S i wzmacniacz audio

Do realizacji odtwarzacza wybrałem konwerter DAC z wbudowanym wzmacniaczem. Jak już wspomniałem na wstępie, najczęściej do przesyłania danych audio wykorzystywany jest interfejs I2S. Interfejs I2S wykorzystuje 4 linie sygnałowe. Są nimi:

  • SCK – linia zegarowa
  • LRCK – linia aktywnego kanału audio (prawy, lewy)
  • SDATA – linia danych
  • MCLK – linia zewnętrznego sygnału zegarowego (nie zawsze wymagana)

Nazewnictwo może różnić się w zależności od producenta układu, dlatego zawsze warto zajrzeć do opisu linii, aby rozwiać wszelkie wątpliwości. Ponadto warto zauważyć, że linia MCLK nie zawsze jest wymagana do prawidłowej pracy układu.

Układ MAX98357A to konwerter DAC z wbudowanym jednokanałowym wzmacniaczem audio klasy D. Może generować sygnał analogowy o rozdzielczości 16, 24 lub 32 bitów. W projekcie wykorzystamy gotowy moduł ze wzmacniaczem w postaci płytki Adafruit.

Na płytce PCB umieszczono wszystkie niezbędne elementy pasywne potrzebne do prawidłowej pracy układu. Na złączu wyprowadzone mamy piny, w tym:

NazwaOpis
+, –Wyjście audio
VinZasilanie 2,5 V do 5,5 V
GNDMasa
SDTryb
GAINWzmocnienie
DINWejście danych
BCLKLinia zegarowa
LRCLinia aktywnego kanału

Do wyjścia audio podłączany jest głośnik. Interfejs I2S komunikuje się przez linie DIN, BCLK i LRC (jak widać różnią się tutaj oznaczenia od podanych wcześniej, a w dokumentacji STM32 widnieją jeszcze inne). Pin SD odpowiada za tryb pracy (wyłączony, jeden z kanałów lub średnia z obu kanałów). Moduł Adafruit został skonfigurowany tak, aby przy zasilaniu napięciem 5 V układ był w trybie „średnia z dwóch kanałów” i z tego trybu będziemy korzystali. Pin Gain odpowiada za wzmocnienie i domyślnie wykorzystujemy 9 dB. Poza tym układ może być zasilany napięciem od 2,5 V do 5 V.

Konfiguracja mikrokontrolera

Do odtwarzania dźwięków (podobnie jak w przypadku wbudowanego konwertera DAC) wykorzystamy interfejs I2S w połączniu z kontrolerem DMA. Tworzymy zatem nowy projekt i przechodzimy do konfiguracji. W mikrokontrolerach z serii F1, czy F4 mamy do dyspozycji interfejs I2S, który jest częściowo połączony z SPI. W serii L4 dostępny jest trochę bardziej rozbudowany interfejs o nazwie SAI (Serial Audio Interface), który ma możliwość pracy w konfiguracji I2S.

Przechodzimy do zakładki „Multimedia” i wybieramy SAI1. Każdy interfejs SAI składa się z dwóch bloków. My potrzebujemy jednego z nich, dlatego wybieramy SAI A, tryb „Master” (jeżeli konwerter I2S wymaga sygnału MCLK to tutaj należy wybrać „Master with Master Clock Out) i zaznaczamy opcję „I2S/PCM Protocol”.

Następnie przechodzimy do konfiguracji bloku SAI A. Będziemy wysyłali dane, dlatego wybieramy tryb audio jako „Master Transmit”. Jak już opisywałem, do realizacji projektu wybrałem wzmacniacz monofoniczny i pliki, które będę obsługiwał również będą mono, dlatego tryb wyjścia konfiguruję jako „Mono”. Następnie wybieramy protokół „I2S Standard” z rozdzielczością 16 bitów. Na koniec musimy skonfigurować jeszcze odpowiednio zegar. Zakładam, że będę chciał odtwarzać dźwięki o częstotliwości próbkowania 44,1 kHz, zatem wybieram taką wartość w polu „Audio Frequency”.

W zależności od tego, jak skonfigurujemy zegar mikrokontrolera, wartość w polu „Real Audio Frequency” może znacznie odbiegać od tej, którą wybraliśmy (nawet o kilkadziesiąt procent). Może być potrzeba odpowiedniego skonfigurowania zegara I2S „ręcznie”, aby otrzymać założone parametry. Przechodzimy zatem do zakładki „Clock Configuration” i modyfikujemy wartość w obszarze PLLSAI1 tak, aby otrzymać częstotliwość próbkowania z jak najmniejszym błędem. Interesuje nas, aby zegar końcowy był jak najbliżej jednej z wielokrotności ustawionej częstotliwości 44,1 kHz. W moim przypadku tak zmodyfikowałem mnożnik N i dzielnik P, że uzyskałem na wyjściu zegar SAI1 ok. 11,3 MHz, czyli wartość ok 256 razy większą. W rezultacie błąd w częstotliwości próbkowania będzie na poziomie 0,26%, co z pewnością nie będzie słyszalne przez człowieka.

Dobranie odpowiedniej częstotliwości zegara jest uciążliwe, jednak zauważmy, że dla najczęściej stosowanych częstotliwości próbkowania audio, czyli 44,1 kHz, 22 kHz, czy 11 kHz, wartość częstotliwości zegara SAI będzie kompatybilna. Jeżeli chcielibyśmy stworzyć uniwersalny odtwarzacz, musielibyśmy tutaj się trochę natrudzić z odpowiednią konfiguracją z poziomu kodu.

Na koniec dodajemy jeszcze obsługę DMA w zakładce „DMA Settings”. Wybieramy kierunek przysłania danych „Memory to Peripheral”, tryb jako „Normal”, inkrementację adresów tylko w przypadku pamięci i długość danych „Half Word” w obu oknach.

Wyjścia I2S będziemy mieli dostępne na pinach:

  • PB9 (SAI1_FS_A) – Frame Synchronization line, czyli linia aktywnego kanału audio (prawy, lewy)
  • PC3 (SAI1_SD_A) – linia danych
  • PB10 (SAI_SCK_A) – linia zegarowa

Konfiguracja pinów mikrokontrolera wygląda następująco.

Mając tak skonfigurowane układy peryferyjne mikrokontrolera możemy wygenerować projekt (Alt+K).

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 interfejsu SAI. 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
{
    SAI_HandleTypeDef *hsai;

    uint8_t *data_pointer;
    uint8_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 block interfejsu SAI.

void wave_player_init(SAI_HandleTypeDef *_hsai)
{
    wave_player.hsai = _hsai;
}

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ą 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).

Ponieważ interfejs będzie przesyłał 16 bitowe próbki danych, a my wykorzystujemy bufor 8-bitowy, będziemy przesyłali za każdym razem 2 razy mniej próbek niż rozmiar naszego bufora. Wynika to z tego, że w konfiguracji DMA ustawiliśmy długość danych jako Half Word, czyli 2 bajty.

void wave_player_start(uint8_t *file)
{
	int8_t status;

	status = wave_player_read_header(file);

	if(ERROR == status)
	{
		return;
	}

	wave_player_set_sample_rate(wave_player.file_hdr.wave_file_hdr.sample_rate);
	wave_player_prepare_first_buffer();

	HAL_SAI_Transmit_DMA(wave_player.hsai, (uint8_t *)wave_player.buffer, AUDIO_BUFFER_SIZE/2);
}

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 skonfigurowanie SAI w trybie Stereo.

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 I2S zgodnie z odczytaną częstotliwością próbkowania. Nie obsługiwałem tutaj zmiany ustawień zegara, dlatego w ten sposób będzie poprawnie działał odtwarzacz dla częstotliwości 11 kHz, 22 kHz oraz 44,1 kHz.

void wave_player_set_sample_rate(uint32_t sample_rate)
{
	wave_player.hsai->Init.AudioFrequency = sample_rate;

	HAL_SAI_InitProtocol(wave_player.hsai, SAI_I2S_STANDARD, SAI_PROTOCOL_DATASIZE_16BIT, 2);
}

Następnie przygotowujemy dane w buforze.

void wave_player_prepare_first_buffer(void)
{
	wave_player_prepare_data(0, AUDIO_BUFFER_SIZE);
}

void wave_player_prepare_data(uint32_t start_address, uint32_t end_address)
{
	uint8_t audio_sample;

	for(int i = start_address; i < end_address; i++)
	{
		audio_sample = *(wave_player.data_pointer+wave_player.byte_counter);

		wave_player.buffer[i] = audio_sample;
		wave_player.byte_counter++;

		if(wave_player.byte_counter >= wave_player.file_hdr.wave_file_hdr.data_size)
		{
			HAL_SAI_DMAPause(wave_player.hsai);
			return;
		}
	}
}

Po wystartowaniu SAI 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.

void wave_player_prepare_half_buffer(uint8_t half_number)
{
	if(FIRST_HALF_OF_BUFFER == half_number)
	{
		wave_player_prepare_data(0, AUDIO_BUFFER_SIZE/2);
	}
	else if(SECOND_HALF_OF_BUFFER == half_number)
	{
		HAL_SAI_Transmit_DMA(wave_player.hsai, (uint8_t *)wave_player.buffer, AUDIO_BUFFER_SIZE/2);
		wave_player_prepare_data(AUDIO_BUFFER_SIZE/2, AUDIO_BUFFER_SIZE);
	}
}

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

/* USER CODE BEGIN 4 */
void HAL_SAI_TxCpltCallback(SAI_HandleTypeDef *hsai)
{
	wave_player_prepare_half_buffer(SECOND_HALF_OF_BUFFER);
}

void HAL_SAI_TxHalfCpltCallback(SAI_HandleTypeDef *hsai)
{
	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(&hsai_BlockA1);
  wave_player_start(audio_file);
  /* USER CODE END 2 */

Na koniec musimy jeszcze prawidłowo podłączyć wzmacniacz do mikrokontrolera. Potrzebujemy 5 pinów: Vin do 5 V, GND, DIN do PC3, BCLK do PB10 oraz LRC do PB9. Sposób podłączenia przedstawia poniższy schemat.

Podsumowanie

W materiale przedstawiłem, w jaki sposób zrealizować odtwarzanie plików WAVE przy pomocy zewnętrznego konwertera DAC i interfejsu I2S. Zastosowanie zewnętrznego przetwornika daje nam znacznie większe możliwości niż wbudowany DAC – możemy odtwarzać dźwięki 16, 24, a nawet 32 bitowe. Z takim zapleczem (i oczywiście odpowiednim wzmacniaczem audio) jesteśmy w stanie zbudować dobrej jakości odtwarzacz muzyki.

Do pobrania

Repozytorium GitHub

Dodaj komentarz

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