← На главную

Учимся передавать звук с использованием протокола I2S

I2S (Inter-IC Sound) – это цифровой протокол передачи звука, который довольно часто используется в современной электронике. I2S не имеет ничего общего с I2C кроме похожего названия, поэтому важно эти протоколы не путать. В рамках поста мы постараемся разобраться, на что вообще похож I2S, и как с ним работать.

На базе устройств, использующих I2S, существует немало готовых плат, в частности:

При написании этого поста я использовал PmodI2S производства компании Digilent. Данный модуль построен на базе чипа CS4344 (типичная маркировка «344C 1609», даташит [PDF]). Устройство было приобретено в Чип-и-Дипе, но на момент написания этих строк оно успело куда-то пропасть с сайта магазина. Впрочем, для повторения описанных далее шагов вы можете использовать любой аналогичный модуль. Внешний вид использованного мной модуля:

Модуль PmodI2S на базе чипа CS4344

Типичный I2S-сигнал выглядит в PulseView как-то так:

I2S-сигнал в PulseView

Здесь SCK представляет собой тактовый сигнал. WS (он же LRCLK) отвечает за выбор канала. Через SDA (он же SDIN) передаются сами данные. Сигнала MCLK, строго говоря, нет в спецификации I2S [PDF]. Но на практике многие устройства используют его для синхронизации своих внутренних операций. Обычно сюда идет тактовый сигнал с частотой в 256 раз больше частоты дискретизации звука.

Fun fact! Если хочется извлечь звук из записанного I2S-сигнала, это можно сделать такой командой:

sigrok-cli -i i2s.sr -P i2s:sd=SDA:ws=WS:sck=SCK -A i2s=right | \ cut -c27-30 | xxd -r -p | \ sox -t raw -B -b 16 -c 1 -e signed-integer -r 48k - audio.wav

Для экспериментов с модулем я воспользовался отладочной платой Nucleo-F411RE. Микроконтроллер, используемый в этой плате, имеет аппаратную поддержку I2S, которой и было решено воспользоваться. Какие настройки доступны в STM32CubeMX и к каким пинам микроконтроллера следует подключать модуль, вы без труда разберетесь самостоятельно по полной версии проекта. Поговорим лучше непосредственно о коде.

Генерация синусоидального сигнала с частотой 100 Гц осуществляется так:

#define PI 3.14159265358979323846 #define TAU (2.0 * PI) void loop() { HAL_StatusTypeDef res; int16_t signal[46876]; int nsamples = sizeof(signal) / sizeof(signal[0]); int i = 0; while(i < nsamples) { double t = ((double)i/2.0)/((double)nsamples); signal[i] = 32767*sin(100.0 * TAU * t); // left signal[i+1] = signal[i]; // right i += 2; } while(1) { res = HAL_I2S_Transmit(&hi2s2, (uint16_t*)signal, nsamples, HAL_MAX_DELAY); if(res != HAL_OK) { UART_Printf("I2S - ERROR, res = %d!\r\n", res); break; } } }

Интересно, что по каким-то причинам микроконтроллеры STM32 не могут использовать традиционные значения частоты дискретизации, такие, как 44100 Гц или 48000 Гц. В частности, при выборе частоты 48000 Гц реальная частота составит 46876 Гц (на 2.34% меньше). Впрочем, на слух такая разница совершенно незаметна. Все эти различия между желаемой и реальной частотой отображаются прямо в STM32CubeMX.

Подключаем осциллограф, проверяем:

Проверяем звук, переданный по I2S, с помощью осциллографа

Но это еще не все. Если вы попытаетесь, например, просто взять и проиграть WAV-файл с SD-карты «в лоб», то у вас ничего не получится. Звук будет периодически обрываться и слушать такое будет совершенно невозможно. Решение заключается в том, чтобы использовать прерывания и двойную буфферизацию. Другими словами, параллельно с проигрыванием одного отрывка файла должен читаться следующий отрывок. Таким образом, когда проигрывание текущего отрывка завершится, следующий отрывок будет уже готов, и не придется тратить время на его чтение с SD-карты (что и является источником обрывов в звуке).

Соответствующий код:

volatile bool end_of_file_reached = false; volatile bool read_next_chunk = false; volatile uint16_t* signal_play_buff = NULL; volatile uint16_t* signal_read_buff = NULL; volatile uint16_t signal_buff1[4096]; volatile uint16_t signal_buff2[4096]; void HAL_I2S_TxCpltCallback(I2S_HandleTypeDef *hi2s) { if(end_of_file_reached) return; volatile uint16_t* temp = signal_play_buff; signal_play_buff = signal_read_buff; signal_read_buff = temp; int nsamples = sizeof(signal_buff1) / sizeof(signal_buff1[0]); HAL_I2S_Transmit_IT(&hi2s2, (uint16_t*)signal_play_buff, nsamples); read_next_chunk = true; } int playWavFile(const char* fname) { UART_Printf("Openning %s...\r\n", fname); FIL file; FRESULT res = f_open(&file, fname, FA_READ); if(res != FR_OK) { UART_Printf("f_open() failed, res = %d\r\n", res); return -1; } UART_Printf("File opened, reading...\r\n"); unsigned int bytesRead; uint8_t header[44]; res = f_read(&file, header, sizeof(header), &bytesRead); if(res != FR_OK) { UART_Printf("f_read() failed, res = %d\r\n", res); f_close(&file); return -2; } if(memcmp((const char*)header, "RIFF", 4) != 0) { UART_Printf("Wrong WAV signature at offset 0: " "0x%02X 0x%02X 0x%02X 0x%02X\r\n", header[0], header[1], header[2], header[3]); f_close(&file); return -3; } if(memcmp((const char*)header + 8, "WAVEfmt ", 8) != 0) { UART_Printf("Wrong WAV signature at offset 8!\r\n"); f_close(&file); return -4; } if(memcmp((const char*)header + 36, "data", 4) != 0) { UART_Printf("Wrong WAV signature at offset 36!\r\n"); f_close(&file); return -5; } uint32_t fileSize = 8 + (header[4] | (header[5] << 8) | (header[6] << 16) | (header[7] << 24)); uint32_t headerSizeLeft = header[16] | (header[17] << 8) | (header[18] << 16) | (header[19] << 24); uint16_t compression = header[20] | (header[21] << 8); uint16_t channelsNum = header[22] | (header[23] << 8); uint32_t sampleRate = header[24] | (header[25] << 8) | (header[26] << 16) | (header[27] << 24); uint32_t bytesPerSecond = header[28] | (header[29] << 8) | (header[30] << 16) | (header[31] << 24); uint16_t bytesPerSample = header[32] | (header[33] << 8); uint16_t bitsPerSamplePerChannel = header[34] | (header[35] << 8); uint32_t dataSize = header[40] | (header[41] << 8) | (header[42] << 16) | (header[43] << 24); UART_Printf( "--- WAV header ---\r\n" "File size: %lu\r\n" "Header size left: %lu\r\n" "Compression (1 = no compression): %d\r\n" "Channels num: %d\r\n" "Sample rate: %ld\r\n" "Bytes per second: %ld\r\n" "Bytes per sample: %d\r\n" "Bits per sample per channel: %d\r\n" "Data size: %ld\r\n" "------------------\r\n", fileSize, headerSizeLeft, compression, channelsNum, sampleRate, bytesPerSecond, bytesPerSample, bitsPerSamplePerChannel, dataSize); if(headerSizeLeft != 16) { UART_Printf("Wrong `headerSizeLeft` value, 16 expected\r\n"); f_close(&file); return -6; } if(compression != 1) { UART_Printf("Wrong `compression` value, 1 expected\r\n"); f_close(&file); return -7; } if(channelsNum != 2) { UART_Printf("Wrong `channelsNum` value, 2 expected\r\n"); f_close(&file); return -8; } if((sampleRate != 44100) || (bytesPerSample != 4) || (bitsPerSamplePerChannel != 16) || (bytesPerSecond != 44100*2*2) || (dataSize < sizeof(signal_buff1) + sizeof(signal_buff2))) { UART_Printf("Wrong file format, 16 bit file with sample " "rate 44100 expected\r\n"); f_close(&file); return -9; } res = f_read(&file, (uint8_t*)signal_buff1, sizeof(signal_buff1), &bytesRead); if(res != FR_OK) { UART_Printf("f_read() failed, res = %d\r\n", res); f_close(&file); return -10; } res = f_read(&file, (uint8_t*)signal_buff2, sizeof(signal_buff2), &bytesRead); if(res != FR_OK) { UART_Printf("f_read() failed, res = %d\r\n", res); f_close(&file); return -11; } read_next_chunk = false; end_of_file_reached = false; signal_play_buff = signal_buff1; signal_read_buff = signal_buff2; HAL_StatusTypeDef hal_res; int nsamples = sizeof(signal_buff1) / sizeof(signal_buff1[0]); hal_res = HAL_I2S_Transmit_IT(&hi2s2, (uint16_t*)signal_buff1, nsamples); if(hal_res != HAL_OK) { UART_Printf("I2S - HAL_I2S_Transmit failed, " "hal_res = %d!\r\n", hal_res); f_close(&file); return -12; } while(dataSize >= sizeof(signal_buff1)) { if(!read_next_chunk) { continue; } read_next_chunk = false; res = f_read(&file, (uint8_t*)signal_read_buff, sizeof(signal_buff1), &bytesRead); if(res != FR_OK) { UART_Printf("f_read() failed, res = %d\r\n", res); f_close(&file); return -13; } dataSize -= sizeof(signal_buff1); } end_of_file_reached = true; res = f_close(&file); if(res != FR_OK) { UART_Printf("f_close() failed, res = %d\r\n", res); return -14; } return 0; }

Передача данных по I2S осуществляется асинхронно при помощи процедуры HAL_I2S_Transmit_IT. По завершении передачи данных вызывается коллбэк HAL_I2S_TxCpltCallback. Если это известно, то остальная часть кода становится тривиальной.

Напомню, что с форматом WAV-файлов и библиотекой FatFs мы ранее познакомились в рамках статей Парсинг заголовка и проигрывание WAV-файла на Scala и Работа с FAT32 и exFAT с помощью библиотеки FatFs соответственно.

Это все, о чем я хотел рассказать. Исходники к посту вы найдете на GitHub.