Tekst zgodny z esp-idf 5.5.1+
Część dla esp8266 dostępna pod linkiem: <wkrótce>
Jednym z ciekawszych komponentów dostępnych w ramach mirkokontrolerów esp niewątpliwie jest i2s. I2S (nie mylić z I2C) to szeregowy standard wymiany danych audio między układami cyfrowymi. Komunikują się za jego pomocą układy mikrofonowe, wzmacniacze lub wszelkiego rodzaju sprzętowe manipulatory/korektory dźwięku.
Konfiguracja i obsługa i2s dla esp32 w esp-idf nie jest mocno skomplikowana co postaram się w tym artykule przedstawić podpinając do esp32 układ mikrofonowy typu MEMS (ICS-43434) i rejestrując nim dźwięk z otoczenia.
Mikforon MEMS I2S
Jeszcze wspomnę o samym mikrofonie jakiego będę używał. ICS-43434, to jeden z wielu dostępnych dzisiaj na rynku układów mikrofonowych ( takich jak SPH0645LM4H, INMP441, SPW2430 itp.) wykonanych w technice MEMS (microelectromechanical system).
Układy MEMS charakteryzują się tym, że w ich strukturze występują mikroskopijne elementy mechaniczne, które służą określonym zadaniom. W mikrofonie jest to jakaś membrana wpadająca w drgania przenoszone falą akustyczną poprzez powietrze. Poza nią znajdziemy w środku ASIC (układ scalony dedykowanego przeznaczenia), który zajmuje się samplingiem, filtracją i wystawianiem sygnału w standardzie i2s.
Zaletą tych mikrofonów jest ich rozmiar, są wielkości ziarenka ryżu; Brak konieczności użycia układów wzmacniających sygnał jak w przypadku pracy z klasycznym elektretem, co ogólnie wpływa na prostotę obsługi, wielkość i wydajność energetyczną.
Protokół I2S
I2S jest szeregowym standardem do cyfrowego przesyłu danych dźwiękowych. Dane przesyłane są w formie PCM (Pulse Code modulation), a więc są „najczystszą” możliwą reprezentacją sygnału analogowego w sposób cyfrowy. W takiej postaci są zapisane na przykład dane w pliku dźwiękowym wav. I do takiego formatu są przez komputery konwertowane wszelkie zapisy audio przed puszczeniem je na układ DAC, który generuje już przebieg analogowy idący na wzmacniacz głośnika.
Dlatego też pliki w formacie wav zajmują aż tyle przestrzeni, PCM to po prostu odczyt samplingu przez układ ADC fali analogowej. Jeżeli przykładowo rejestrujemy z pomocą naszego mikrofonu dźwięk w standardzie 24-bit/44.1kHz to w pliku wav, poza danymi nagłówkowymi, typowymi dla tego standardu, musimy zapisać aż 44100 odczytów reprezentowanych przez 24-bitową liczbę na sekundę nagrania. A to będzie ok. 8MB na minutę takiego nagrania w trybie MONO (jeden kanał, stereo – *2).
Wszelkie formy kompresji danych audio, nieważne czy stratne, czy nie, polegają na przepuszczeniu dodatkowo takich surowych danych przez skomplikowane algorytmy matematyczne, które odfiltrowują dane jakie będzie można odzyskać z pomocą późniejszej analizy i predykcji na etapie dekodowania z powrotem do PCM (w przypadku standardów stratnych, część danych oczywiście przepada).
Finalnie każdy taki skompresowany strumień danych i tak trzeba przetworzyć do zapisu w standardzie PCM by móc go odsłuchać/przetworzyć. W komputerach kompresja i dekompresja odbywa się programowo, natomiast na smartfonach, tabletach, urządzeniach mniej wydajnych, najczęściej sprzętowo (dla typowych kodeków).
Opis powyżej zawarłem byś miał taki minimalny pogląd na to jak wygląda reprezentacja dźwięku przez komputery.
Do transmisji wykorzystywane są trzy linie:
- SCK/BCK – Sygnał zegarowy generowany przez urządzenie Master
- SD – Dane
- WS (Word Select) – Sygnalizuje aktualnie przesyłany kanał (0 – kanał lewy, 1 – kanał prawy)

I2S vs PDM
W wielu przykładach, jak i w samej dokumentacji ESP-IDF możemy natknąć się na termin PDM. PDM to akronim od Pulse Density Modulation (Modulacja Gęstości Impulsów). Standard I2S polega na przesyle liczb całkowitych, reprezentujących stosunek napięcia zmierzonego do napięcia odniesienia.
Natomiast w transmisji PDM przesyłany jest ciąg zer i jedynek, którego gęstość bitów 1 odwzorowuje wartość sygnału analogowego w danym przedziale czasowym.
Innymi słowy, chodzi o odtworzenie kształtu przebiegu analogowego poprzez odpowiednie zagęszczenie jedynek w ciągu binarnym. Proces generowania sygnału binarnego PDM polega na modulacji Delta-Sigma.

Kontroler I2S w układach esp32 obsługuje standard PDM, a także zamianę w czasie rzeczywistym pdm-pcm i pcm-pdm, ale tylko w wybranych modułach. Jeżeli będziemy mieć zamiar eksperymentować ze standardem PDM, koniecznie należy zapoznać się z poniższą tabelą.

Zaletą modulacji gęstości impulsów jest fakt, że układy peryferyjne np. mikrofony mems, nie wymagają wykonywania zaawansowanych operacji do wygenerowania takiego sygnału. To z kolei przekłada się na mniejszy pobór energii tychże układów.
Sygnał pdm będzie też mniej podatny na zakłócenia.
ESP-IDF i2s
Przejdę więc do najważniejszego, a więc do konfiguracji kontrolera i2s już w ramach środowiska esp32. Jak wspomniałem na początku, celem tej części kursu będzie uruchomienie i2s w trybie mastera, podłączenie mikrofonu typu mems – ICS-43434; i zapisanie tego co on zarejestruje.
Zapis zrealizuje zdalnie poprzez WiFi i połączenie UDP między esp32, a moim komputerem. Wynikowy plik będzie w standardzie 24-bit/12kHz MONO (dla stereo trzeba użyć dwóch mikrofonów).
Przesył zrobimy zdalny byś zobaczył jak proste są to zadania dla mikrokontrolerów esp. Pominę jednak przetwarzanie sygnału na samym mikrokontrolerze, z niego wysyłać będę jedynie czyste odczyty PCM.
Jeżeli nie jesteś zaznajomiony z sieciowymi kwestiami esp32, to sprawdź części kursu poświęcone konfiguracji wifi, oraz realizacji połączenia UDP.
Kod nr. 1
#include "driver/i2s_std.h" #include "driver/gpio.h" #define SAMPLE_BUFFER_SIZE 128 #define SAMPLE_RATE 8000 #define BIT_RATE 32 #define GPIO_BCLK GPIO_NUM_3 #define GPIO_DIN GPIO_NUM_2 #define GPIO_WS GPIO_NUM_1 #define GPIO_DOUT GPIO_NUM_5 static i2s_chan_handle_t rx_chan;
Na samym początku należy dołączyć odpowiednia bibliotekę. „i2s_std” – „std” od standard, istnieje jeszcze wersja „i2s_pdm”.
Dalej zdefiniowałem kilka makrodefinicji, a na końcu uchwyt kanału.
Kod nr. 2
static void i2s_init(void) {
i2s_chan_config_t i2s_channel_cfg;
i2s_channel_cfg.id = I2S_NUM_0;
i2s_channel_cfg.role = I2S_ROLE_MASTER;
i2s_channel_cfg.dma_desc_num = 3;
i2s_channel_cfg.dma_frame_num = 1023, // 1023 to maksymalna wielkośc jednego bufora!
i2s_channel_cfg.allow_pd = false;
i2s_channel_cfg.intr_priority = 0;
i2s_new_channel(&i2s_channel_cfg, NULL, &rx_chan);
i2s_std_config_t i2s_cfg;
i2s_std_clk_config_t clk_config = {
.clk_src = I2S_CLK_SRC_DEFAULT,
.sample_rate_hz = SAMPLE_RATE,
.ext_clk_freq_hz = 0,
.mclk_multiple = I2S_MCLK_MULTIPLE_256,
.bclk_div = 8,
};
i2s_std_slot_config_t slot_config = {
.data_bit_width = I2S_DATA_BIT_WIDTH_32BIT,
.slot_bit_width = I2S_SLOT_BIT_WIDTH_32BIT,
.slot_mode = I2S_SLOT_MODE_MONO,
.slot_mask = I2S_STD_SLOT_LEFT,
.ws_width = 32,
.ws_pol = false,
.bit_shift = true,
};
i2s_std_gpio_config_t i2s_gpio_config = {
.mclk = I2S_GPIO_UNUSED,
.bclk = GPIO_BCLK,
.ws = GPIO_WS,
.dout = GPIO_DOUT,
.din = GPIO_DIN,
.invert_flags = {
.bclk_inv = false,
.mclk_inv = false,
.ws_inv = false,
},
};
i2s_cfg.clk_cfg = clk_config;
i2s_cfg.slot_cfg = slot_config;
i2s_cfg.gpio_cfg = i2s_gpio_config;
ESP_ERROR_CHECK(i2s_channel_init_std_mode(rx_chan, &i2s_cfg));
ESP_ERROR_CHECK(i2s_channel_enable(rx_chan));
}
Powyżej znajduje się cała gotowa konfiguracja kanału i2s do działania z podłączonym mikrofonem.
Zaczynamy od zdefiniowania struktury „i2s_chan_config_t”.
i2s_chan_config_t
Poniżej dostępne zmienne, jak jednak widzisz nie wszystko musimy ustawiać.
- id – numer portu I2S
- role – tryb pracy Master/Slave
- dma_desc_num – ilość buforów DMA
- dma_frame_num – wielkość buforów DMA.
Wzór: dma_buffer_size = dma_frame_num * slot_num * slot_bit_width / 8
- auto_clear_after_cb – czyści bufor DMA TX po callbacku
- auto_clear_before_cb – czyści bufor DMA TX przed callbackiem
- allow_pd – jeżeli true, to w przypadku usypiania esp, komponent I2S też zostanie uśpiony
- intr_priority – priorytet przerwań z I2S
I2S DMA
Warto się tutaj zatrzymać przy tych dwóch zmiennych struktury odpowiedzialnych za definicję buforów DMA. Zmienna „dma_frame_num” definiuje wielkość tych buforów, natomiast „dma_desc_num” ich ilość. To jest bardzo kluczowe dla oczekiwanej pracy układu.
DMA (Direct Memory Access) to osobny komponent, który umożliwia peryferiom mikrokontrolera przekazywanie danych bezpośrednio z/do pamięci, bez ingerencji procesora.
W momencie jednak gdy DMA zapisuje dane do bufora, procesor w tym samym czasie nie może tych danych odczytywać. Dlatego też tworzymy więcej niż jeden bufor, dzięki temu DMA może dokonywać zapisu do jednego bufora, a procesor może z drugiego (wolnego) odczytywać.
I zasada generalnie jest taka:
– Większa ilość buforów o mniejszej wielkości – jeżeli potrzebujemy szybszego dostępu do danych
– Mniejsza ilość buforów o większej wielkości – jeżeli nie zależy nam na szybkim dostępie
To wszystko więc zależy od tego co robimy z danymi, jeżeli dane puszczamy od razu na jakiś wzmacniacz to potrzebujemy rozwiązania o niskim opóźnieniu (by nie wystąpiły zacięcia przy odtwarzaniu), a więc mniejsze bufory – by DMA szybko je zwalniało i moglibyśmy jak najszybciej te dane przejmować.
Jeśli chodzi o nasz dzisiejszy przykład, to opóźnienie w dostępie do danych nie gra roli, bo te i tak rejestrujemy by zapisać je w pliku WAV do późniejszego odtworzenia. Skoro jednak będziemy wysyłać te dane przez UDP, a to niewątpliwie doda swoje opóźnienie (odczyt paczki danych z bufora->wysyłka po UDP i tak w koło) – bezpieczniej zrobić te 3 bufory by nie nastąpił overflow jakiegoś z nich.
Poniżej wstawiłem taki koncepcyjny GIF. My chcemy by CPU szybciej opróźniało bufor niż widać to na gifie, by to procesor oczekiwał na zwolnienie przez DMA tego pełnego. Jeżeli byłaby sytuacja, że CPU nie nadąża z opróżnianiem bufora na czas, należy utworzyć kolejny.

i2s_std_clk_config_t
Struktura zawierająca zmienne konfiguracyjne zegara transmisji I2S.
- clk_src – źródło zegara
- sample_rate_hz – częstotliwość samplingu
- mclk_multiple – mnożnik Master Clock, jako że mój sample rate to zaledwie 12kHz to wybrałem najniższe możliwe ustawienie. MCLK służy do generowania BCLK. Najczęściej stosuje się mnożnik x256, w przypadku ustawienia slotu o długości 24-bit powinien być on wielokrotnością 3
i2s_std_slot_config_t
Tutaj konfigurujemy właściwości „slotu”, a więc jednej ramki danych. Mikrofon jakiego używam (ICS-43434) korzysta z typowego formatu Philips, gdzie występuje jedno-bitowe przesunięcie linii WS. Mikrofon podaje 24-bitowe dane w formie 32-bitowej ramki, wartość „data_bit_width” ustawiam także na 32bit, po stronie komputera po prostu dokonam wyodrębnienia tylko tych 24 interesujących mnie bitów.
- data_bit_width – długość danych w bitach
- slot_bit_width – długość w bitach całej ramki
- slot_mode – mono/stereo
- slot_mask – wybór kanału (lewy/prawy/oba)
- ws_width – długość (w cyklach bclk) trwania sygnału WS (zazwyczaj pokrywa się z slot_bit_width)
- ws_pol – polaryzacja sygnału ws (false – domyślnie; 0 – lewy kanał, 1 – prawy; true – odwrotnie)
- bit_shift – przesunięcie bitowe jak w naszym przykładzie, widać je na poniższej grafice

Pominę opis ostatniej struktury odpowiadającej za przydzielanie wyprowadzeń… Kolejno opisane struktury przekazujemy wcześniej zadeklarowanej strukturze ogólnej typu „i2s_std_config_t”. Kolejno inicjalizujemy channel i można go od razu uruchomić.
Odczyt danych z mikrofonu I2S
Po konfiguracji komponentu I2S można przystąpić do odbierania danych nadchodzących z mikrofonu. Zrealizowałem to w ramach pętli głównej programu w sposób widoczny na poniższym kodzie.
Jeszcze przed pętlą zdefiniowałem sobie tablicę na odczytane dane z bufora DMA, które wyciąga funkcja „i2s_channel_read”. Ta przyjmuje uchwyt channela, wskaźnik do owej tablicy, jej wielkość, a także wskaźnik do wcześniej definiowanej zmiennej typu „size_t”.
W tej zmiennej będzie zapisywana ilość faktycznie odebranych danych przez „i2s_channel_read”. Na samym końcu podaję wartość timeout (to blokująca funkcja), po jakim esp ma porzucić funkcję.
int32_t raw_samples[SAMPLE_BUFFER_SIZE];
while(1) {
size_t bytes_read = 0;
i2s_channel_read(rx_chan, raw_samples, 128, &bytes_read, 100);
int samples_read = bytes_read / sizeof(int32_t);
if (samples_read > 0) {
sendto(s, (uint8_t*)raw_samples, samples_read * sizeof(int32_t), 0, (struct sockaddr *)&conn_config, sizeof(conn_config));
}
}
Odczywiście cały kod, wraz z konfiguracją Wi-Fi i clienta UDP dostępny na githubie w linku poniżej:
https://github.com/mcinm/esp-idf/blob/main/kurs11/i2s_mikro_udp_client.c
Pozostało jeszcze w jakiś sposób odebrać te dane, to oczywiście robię po stronie komputera i używam w tym celu prostego skryptu napisanego w pythonie.
Skrypt binduje socket na porcie 8876, odbiera dane, gdy nastąpi zatrzymanie programu (ctrl+C) odebrane dane przekształca w plik WAV. Te proste programy na pewno będą dobrym wyjściem do bardziej ambitnych zastosowań.
Kod python – odbiór danych (server UDP) i konwersja do pliku wav:
https://github.com/mcinm/esp-idf/blob/main/kurs11/i2s_mic_receiver.py