Ten wpis będzie takim małym rozszerzeniem części kursu esp32 poświeconej peryferium I2S. Podobnie jak tam, użyję dzisiaj mikrofonu MEMS – ICS-43434, ale także układu DAC (PCM5102), również komunikującego się po I2S.
Celem jest zrobienie dwóch układów, których sercem będzie esp32c3 i zrealizowania pomiędzy nimi pełnej, równoległej komunikacji, tak że oba układy w tym samym czasie nadają, oraz odbierają dźwięk. Coś w rodzaju takiego domofonu/telefonu. Wpis będzię świetnym wprowadzeniem do budowania realnych urządzeń audio z esp32.
Programować układy będę w ramach środowiska esp-idf w wersji 5.5.2.
Zapraszam do sprawdzenia dostępnego na blogu kursu esp32 dla środowiska esp-idf:
Kurs esp32 – Przydatne mogą być zwłaszcza części dotyczące konfiguracji Wi-Fi, I2S, oraz protokołu UDP.
Repozytorium: https://mcinm.pl/pliki/cd/esp32_intercom/
Komponenty:
- DAC PCM5102 – https://mcinm.pl/PCM5102
- Mikrofon ICS-43434 – https://mcinm.pl/ics43434
- Moduł esp32c3 – https://mcinm.pl/esp32c3-supermini
esp32c3 super mini
Korzystam tutaj z takich malutkich modułów, które dostać można nawet poniżej 10zł i zawierają esp32c3. Ta wersja esp32 wyróżnia się mniejszym rozmiarem i poborem prądu, dodatkowo zawiera wbudowaną pamięć flash (nie każda wersja chipu), ale ma także swoje gorsze strony, głównie w postaci ograniczeń możliwości peryferiów, posiada jeden rdzeń, czy niższe maksymalne taktowanie. Mimo tego, dalej jest to świetny układ, na którym zrealizujemy ogrom ambitnych projektów.
Przy wyborze płytki „super mini” warto jednak zwrócić uwagę na sektor anteny Wi-Fi/BLE, na Chińskich marketplace’ach można znaleźć kilka wersji tych modułów i właśnie różnie są one zaprojektowane. Najlepiej wybierać takie, na których widać ewidentnie wydzieloną przestrzeń na antenę.
Są metody by poprawić warunki tych anten przez przylutowanie krótkiego drutu, jak przedstawiono to w artykule z poniższego linku:
https://peterneufeld.wordpress.com/2025/03/04/esp32-c3-supermini-antenna-modification/
Pozostałe komponenty – Mikrofon, DAC
Poza sercem układu, czyli wspomnianym es32c3, używać będę mikrofonu typu mems – ICS-43434. To 24-bitowy mikrofon dookólny, komunikujący się po interfejsie I2S. Natomiast DAC (Digital-to-Analog Converter), czyli układ, który będzie podawane przez nas gołe dane PCM zamieniał w sygnał analogowy, to powszechnie dostępny PCM5102.
Jednak można użyć także innych modułów, nie powinno być większych problemów z działaniem.

Program testowy – lokalny odsłuch mikrfonu I2S

Zaczynam od podłączenia komponentów ze sobą według powyższego schematu, a kolejno wrzucam na oba układy kod, którego zadaniem będzie ciągły odczyt danych z mikrofonu i puszczenie tego na własny DAC. Na razie jeszcze nie wysyłam tych odczytów.
Cały kod dostępny tutaj: mcinm.pl/pliki/cd/esp32_intercom/test.c
Do gniazda jack jaki zawiera posiadany przeze mnie moduł DAC podłączyłem słuchawki, wgrywam program. Mam odsłuch na żywo! A więc wszystko działa prawidłowo, to samo robię dla drugiego układu… Działa, także już wiadomo, że z połączeniem wszystko gra, ewentualne przyszłe błędy, będą już wynikały z dalszej obsługi programowej.
Korzystam tutaj z tylko jednego interfejsu I2S, esp32c3 nie dysponuje drugim, ale to nie problem. Bez najmniejszego wysiłku ten jeden interfejs całkowicie radzi sobie z komunikacją z obydwoma modułami, naprzemian nadając i odbierając dane.
Transmisja audio – esp32 client-server UDP

Do zbudowania słuchawki odsłuchowej lub megafonu, absurdalnym wydaje się stosowanie 32-bitowego mikrokontrolera, dlatego przejdę już do pokazania docelowego założenia.
Jak wyżej wspominałem moim celem jest zbudowanie czegoś w rodzaju telefonu, nie chcę walkie-talkie, gdzie to jedno urządzenie nadaje, a drugie słucha i nadawać może zacząć dopiero, gdy pierwsze zwolni „linię”. Nie – ma być pełna, równoległa komunikacja umożliwiająca przeprowadzenie komfortowej rozmowy.
Do tego celu postanowiłem wykorzystać protokół UDP. W transmisji audio na żywo integralność nadchodzących danych nie jest aż tak istotna, tak samo jak kompletność danych. Coś tam można na spokojnie po drodze zgubić, kolejność danych też może się czasem pomieszać, na całość wiadomości to finalnie nie wpłynie (oczywiście do pewnego stopnia). Toteż UDP jest idealne, wymaga też mniej zasobów i jest znacznie szybsze.
Oczywiście, wszystko finalnie zależy od docelowego zastosowania. Jeśli realizowałbym projekt zakładający pracę przewodową, mógłbym skorzystać z protokołu ethernet, jednak nie byłby to najlepszy pomysł (przerost formy), zakładając, że zależy nam tylko na tym – zestawieniu połączenia między dwoma punktami. Lepiej byłoby się posłużyć prostszym standardem (USART, rs485).
Natomiast jeśli nasz interkom miałby być zestawem mobilym, wtedy Wi-Fi odpada. Lepiej sięgnąć po zewnętrzny moduł radiowy (np. NRF24l01), lub standard ESP-NOW.
Konfiguracja I2S
Poniżej funkcja konfiguracyjna komponent I2S dla obu układów. Nie będę się tutaj rozwodził za dużo, dokładniej ten temat opisałem w kursie esp32 I2S, ale kilka kwestii omówię.
Warto uwagę zwrócić na bufory DMA, są one niewielkie gdyż w przypadku odtwarzania nadchodzących danych „real-time” potrzebujemy natychmiastowego dostępu do danych, DMA nie może za długo ich blokować.
Kolejnym aspektem, który muszę zaznaczyć to ustawienie przeze mnie odbieranych danych I2S na 32-bitowe. Mikrofon z jakiego korzystam podaje wartości 24bit, ale w ramach 32-bitowych ramek. Takie, niezmienione, przesyłam i rzucam na DAC, nic z nimi nie robię. Jasne, dla optymalizacji może i warto by przed wysłaniem te dane przesunąć o 8 bitów i przesyłać zakres danych 24-bitowych, a nie – 32-bitowych, wszak to jakieś 6 KB pustych danych na sekundę. Zdaję sobie z tego sprawę, dlatego zaznaczam, że tutaj tego nie robię 😉 . Gdyby jednak się na taki zabieg zdecydować, myślę że najlepszym sposobem byłby podział tych danych na tablicę 8-bitowych danych i przesył w takiej formie.
#define SAMPLE_BUFFER_SIZE 128
#define SAMPLE_RATE 6000
#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;
static i2s_chan_handle_t tx_chan;
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 = 4;
i2s_channel_cfg.dma_frame_num = 100,
i2s_channel_cfg.allow_pd = false;
i2s_channel_cfg.intr_priority = 0;
i2s_new_channel(&i2s_channel_cfg, &tx_chan, &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,
.mclk_multiple = I2S_MCLK_MULTIPLE_192,
};
i2s_std_slot_config_t slot_config = {
.data_bit_width = 32,
.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(tx_chan, &i2s_cfg));
ESP_ERROR_CHECK(i2s_channel_init_std_mode(rx_chan, &i2s_cfg));
ESP_ERROR_CHECK(i2s_channel_enable(tx_chan));
ESP_ERROR_CHECK(i2s_channel_enable(rx_chan));
}esp32 intercom – server
Cały kod dostępny tutaj: https://mcinm.pl/pliki/cd/esp32_intercom/esp32c3_intercom_server.c
Zaczynam od inicjalizacji wifi, oraz i2s. Kolejno prosta implementacja serwera UDP i wartości timeout’u dla funckji recvfrom. Dwa bufory – jeden dla odebranych danych od klienta, drugi dla danych „wyciągniętych” z mikrofonu.
W pętli głównej pozostaje oczekiwać na dane, jeżeli te przyjdą – dokonać zrzutu danych z własnego mikrofonu, przesłać je klientowi (adres IP podajemy ten, który został spisany funkcją recvfrom).
Dalej już tylko pozostaje odebrane na samym początku dane puścić na DAC, ja jeszcze zanim to, dokonuję wzmocnienia tych danych możąc każdy z elementów tablicy.
void app_main(void)
{
wifi_init();
printf("MCINM - I2S Intercom UDP Server!\n");
sleep(5); // Dajmy czas na nawiązanie połączenia z wifi
i2s_init();
struct sockaddr_in serverAddress;
serverAddress.sin_addr.s_addr = INADDR_ANY;
serverAddress.sin_family = AF_INET;
serverAddress.sin_port = htons(SERVER_PORT);
int s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (s < 0 ) {
ESP_LOGE(TAG, "Nie mozna stworzyc socketa: errno %d", errno);
esp_restart();
}
struct timeval timeout;
timeout.tv_sec = 2;
timeout.tv_usec = 0;
setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
int err = bind(s, (struct sockaddr*)&serverAddress, sizeof(serverAddress));
if (err < 0) {
ESP_LOGE(TAG, "Nie udało się zbindować socketa: errno %d", errno);
esp_restart();
}
ESP_LOGI(TAG, "Socket zbindowany na porcie: %d", SERVER_PORT);
// Zmienne, które posłużą do przechowania adresu IP klienta
struct sockaddr_storage source_addr;
socklen_t socklen = sizeof(source_addr);
int32_t i2s_bufor_rx[128];
int32_t i2s_bufor_tx[128];
while (1) {
int len = recvfrom(s, i2s_bufor_rx, sizeof(i2s_bufor_rx), 0, (struct sockaddr*)&source_addr,&socklen);
if (len < 0) {
ESP_LOGE(TAG, "Timeout: errno %d", errno);
}
else {
// Wysyłanie danych w ramach odpowiedzi do klienta
size_t bytes_read = 0;
i2s_channel_read(rx_chan, i2s_bufor_tx, 128, &bytes_read, 100);
int samples_read = bytes_read / sizeof(int);
int err = sendto(s, (uint8_t*)i2s_bufor_tx, samples_read *sizeof(int), 0, (struct sockaddr *)&source_addr, sizeof(source_addr));
// Odtwarzanie wcześniej odebranych danych od klienta
for (uint8_t i=0; i<len; i++) {
i2s_bufor_rx[i] = i2s_bufor_rx[i]*40;
}
i2s_channel_write(tx_chan, i2s_bufor_rx, len, NULL, 10);
}
}
}esp32 intercom – client
Cały kod dostępny tutaj: https://mcinm.pl/pliki/cd/esp32_intercom/esp32c3_intercom_client.c
Program dla klienta jest jeszcze mniej skomplikowany, dzieje się tutaj w zasadzie dokładnie to samo co wyżej. W przypadku klienta warto wartość timeout’u ustawić na niższą by przez różnego rodzaju rozjazdy, układy nie wpadały w takie pętle, gdzie oba w jednym czasie oczekują na dane.
void app_main(void)
{
wifi_init();
printf("MCINM - I2S Intercom UDP Client!\n");
sleep(5); // Dajmy czas na nawiązanie połączenia z wifi
i2s_init();
ESP_LOGI(TAG, "Inicjalizacja pomyślna");
struct sockaddr_in conn_config;
conn_config.sin_addr.s_addr = inet_addr(SERVER_IP);
conn_config.sin_family = AF_INET;
conn_config.sin_port = htons(SERVER_PORT);
int s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (s < 0 ) {
ESP_LOGE(TAG, "Nie mozna stworzyc socketa: %d", errno);
esp_restart();
}
struct timeval timeout;
timeout.tv_sec = 0;
timeout.tv_usec = 9999;
setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
if (setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) < 0) {
ESP_LOGE(TAG, "Nie mozna ustawic timeout'u: %d", errno);
}
int32_t i2s_bufor_rx[SAMPLE_BUFFER_SIZE];
int32_t i2s_bufor_tx[SAMPLE_BUFFER_SIZE];
struct sockaddr_storage source_addr;
socklen_t socklen = sizeof(source_addr);
while(1) {
size_t bytes_read = 0;
i2s_channel_read(rx_chan, i2s_bufor_tx, SAMPLE_BUFFER_SIZE, &bytes_read, 100);
int samples_read = bytes_read / sizeof(int);
if (samples_read > 0) {
sendto(s, (uint8_t*)i2s_bufor_tx, samples_read * sizeof(int), 0, (struct sockaddr *)&conn_config, sizeof(conn_config));
int len = recvfrom(s, i2s_bufor_rx, sizeof(recv_samples), 0, (struct sockaddr*)&source_addr,&socklen);
if (len>0) {
for (uint8_t i=0; i<len; i++) {
i2s_bufor_rx[i] = i2s_bufor_rx[i]*40;
}
i2s_channel_write(tx_chan, i2s_bufor_rx, len, NULL, 10);
}
}
}
}Niżej zamieszczam film z prezentacją jak działa cały system, akurat nie miałem za bardzo nikogo by pokazać działanie w realnym zastosowaniu, ale chyba widać zamierzony efekt 🙂 . Stabilność połączenia zależy oczywiście od jakości sieci Wi-Fi, ale także samych modułów esp. Jak wspominałem wcześniej, te używane przeze mnie, miewają różne problemy. Może warto pomyśleć nad zastosowaniem klasycznych esp32 z anteną w formie ścieżki na PCB, lub modułami z złączem na antenę zewnętrzną.
Dla systemu, w którym w ramach naszego interkomu miałoby działać więcej urządzeń, warto zgłębić zagadnienie UDP Broadcasting. Technika ta umożliwia przesył danych wielu klientom na raz, a całością zarządza router.
Kod realizujący całość systemu jest na tyle prosty, że nie powinno być problemu z przełożeniem tego przykładu a inne protokoły (radiowe/przewodowe) może bardziej adekwatne do opisywanego przeznaczenia.
