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

13 августа 2018

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.

Метки: , , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.