Оцифровка звука на STM32 (АЦП+DMA) и кодирование в Speex для передачи

в 12:10, , рубрики: cubemx, speex, stm32, кодирование аудио, микроконтроллеры, программирование микроконтроллеров, Разработка для интернета вещей, Разработка систем связи, метки:

image В продолжение своей вчерашней статьи на Geektimes про Самодельный USB-свисток с микрофоном, STM32 и ESP8266 на борту хочу рассказать подробнее про реализацию оцифровки и кодирования звука на микроконтроллере STM32.

В статье покажу как настроить проект в STM32CubeMX, собирать данные с АЦП в два кольцевых буфера посредствам DMA, подключить библиотеку Speex и кодировать данные. Возможно многим материал покажется весьма очевидным, но надеюсь хоть кому-то он будет полезен.

Прошу под кат.

Что такое Speex?

Speex — это свободный кодек для сжатия речевого сигнала, который может использоваться в приложениях «голос-через-интернет» (VoIP). Сжатые кодеком Speex данные можно хранить либо в формате хранения звуковых данных Ogg, либо передавать напрямую с помощью пакетов UDP/RTP. © Wiki

Про Speex я узнал из статьи Распознавание речи на STM32F4-Discovery, советую почитать, большая часть кода взята оттуда.

Элементная база

image В статье я буду использовать самую дешевую и распространенную отладочную плату на базе микроконтроллера STM32F103C8T6. К ней должен быть отдельно приобретен программатор. Подход не изменится и для любой платы Discovery. К отладке я подключал микрофонный модуль с усилителем Max9812.

Схему можно посмотреть в статье, указанной в самом начале. Там я завожу на АЦП сигнал прям с выхода Max9812. Для этого на покупном модуле нужно закоротить конденсатор на ноге OUT (Пятой точкой чувствую, что так делать нельзя, но не знаю как правильно). По входу получается сигнал с постоянной составляющей ~1,6V. Его мы отснимаем и в программе приводим к знаковому типу для выполнения кодирования.

Настройка проекта в STM32CubeMX

Создадим новый проект с микроконтроллером STM32F103C8T6. Первым делом указываем, что у нас подключен внешний кварцевый резонатор. Часовой кварц нам сейчас не нужен, хотя на отладочной плате он тоже есть. Не забываем включить интерфейс отладки Serial Wire. Потом включаем необходимый вход АЦП, у меня это IN8 (см. схему в предыдущей статье). Ну и удобный таймер, по которому DMA будет забирать данные из буфера.

image

После этого заходим во вкладку Clock Configuration и настраиваем схему тактирования. У меня получилось так:

image

Я задал частоту для основной периферии микроконтроллера по максимуму в 72 МГц. На Таймеры тоже заведено 72 МГц, запомним это значение. Вы можете сделать по-другому, но тогда и таймер надо будет пересчитать по-своему.

Переходим во вкладку Configuration. Тут нам надо настроить АЦП, DMA и Таймер.

АЦП настраиваем по триггеру таймера 3. Тут же во вкладке DMA выделяем под это первый канал DMA Peripheral To Memory (из переферии в память). Приоритет не важен, если в программе больше ничего нет. Режим — Circular (циклический), размер данных Half Word (полслова, 2 байта) и адрес памяти будет инкрементироваться.

image

Далее настроим таймер. Speex поддерживает кодирование данные в узкой полосе частот (Narrowband, 8 кГц), широкой (wideband, 16 кГц) и ультраширокой (ultra-wideband, 32 кГц). Не будем нагружать контроллер, возьмем по минимуму. Получается контроллер должен отснимать данные с АЦП на частоте 8 кГц. На таймер нам приходит 72 МГц. Считаем:

$Тик.таймера=frac{1}{72000000} (сек)\ Период.для.8кГц=frac{1}{8000}=0.125 (мсек)\ Кол-во.отсчетов.таймера=frac{Период.для.8кГц}{Тик.таймера}=frac{72000000}{8000}=9000$

Настраиваем таймер на значение 8999 (считать ведь он начинает с нуля) и событие по таймеру Update Event. Ставим галочку глобального прерывания.

image

Можно переходить к генерации проекта. Заходим в Project → Serrings. Укажем путь сохранения проекта и размеры стэка и кучи. Для кодирования Speex нам примерно понадобится 0x600 и 0x1600. После этого генерируем для своей среды и открываем, у меня это IAR.

image

imageПодключим библиотеку Speex

Первое, что нужно сделать, скопировать папку STM32F10x_Speex_Lib с библиотекой Speex в папку Drivers проекта. Потом добавим в проект группу libspeex, а в нее следующие файлы (см. скриншот).

В свойствах проекта на вкладке Preprocessor добавим дефайн HAVE_CONFIG_H и следующие дирректории:

$PROJ_DIR$/../Drivers/STM32F10x_Speex_Lib/include
$PROJ_DIR$/../Drivers/STM32F10x_Speex_Lib/libspeex
$PROJ_DIR$/../Drivers/STM32F10x_Speex_Lib/STM32
$PROJ_DIR$/../Drivers/STM32F10x_Speex_Lib/STM32/include
$PROJ_DIR$/../Drivers/STM32F10x_Speex_Lib/STM32/libspeex
$PROJ_DIR$/../Drivers/STM32F10x_Speex_Lib/STM32/libspeex/iar

Попробуем скомпилировать, должно быть все хорошо без варнингов и ошибок.

Программирование

Тут главное писать код в специально отведенных USER CODE BEGIN-END блоках, тогда, в случае необходимости внесения изменений в проект Куба и повторной его генерации, весь ваш код сохранится. Работу с библиотекой я вынесу в отдельный файл speexx.c. Приведу его код и код заголовочного файла speexx.h сразу:

speexx.h

#ifdef HAVE_CONFIG_H
#include "config.h"
#endif
#include <speex/speex.h>
#include "stm32f1xx_hal.h"

#define FRAME_SIZE 160 //*0.125мс = 20мс (сэмплирование 8кГц)
#define ENCODED_FRAME_SIZE 20 //ужимает в 8 раз
#define MAX_REC_FRAMES 90 //максимальное число записываемых фреймов, Время = MAX_REC_FRAMES*0,02сек

extern __IO uint16_t IN_Buffer[2][FRAME_SIZE];
extern __IO uint8_t Start_Encoding;

void Speex_Init(void);
void EncodingVoice(void);

speexx.c

#include "speexx.h"

//SPEEX variables
__IO uint16_t IN_Buffer[2][FRAME_SIZE];
__IO uint8_t Start_Encoding = 0;
uint8_t Index_Encoding = 0;
uint32_t Encoded_Frames = 0;

uint8_t REC_DATA[2][MAX_REC_FRAMES*ENCODED_FRAME_SIZE]; //сюда сохраняются закодированные данные
uint8_t* Rec_Data_ptr = &REC_DATA[0][0]; //указатель на кодируемые данные
uint8_t* Trm_Data_ptr; //указатель на передаваемые данные

int quality = 4, complexity=1, vbr=0, enh=1;/* SPEEX PARAMETERS, MUST REMAINED UNCHANGED */
SpeexBits bits; /* Holds bits so they can be read and written by the Speex routines */
void *enc_state, *dec_state;/* Holds the states of the encoder & the decoder */

void Speex_Init(void)
{
  /* Speex encoding initializations */ 
  speex_bits_init(&bits);
  enc_state = speex_encoder_init(&speex_nb_mode);
  speex_encoder_ctl(enc_state, SPEEX_SET_VBR, &vbr);
  speex_encoder_ctl(enc_state, SPEEX_SET_QUALITY,&quality);
  speex_encoder_ctl(enc_state, SPEEX_SET_COMPLEXITY, &complexity);
}

void EncodingVoice(void)
{
    uint8_t i;
    
    //====================Если одна из половинок буфера заполнена======================
    if(Start_Encoding > 0)
      { 
        Index_Encoding = Start_Encoding - 1;
        for (i=0;i<FRAME_SIZE;i++) IN_Buffer[Index_Encoding][i]^=0x8000;
        /* Flush all the bits in the struct so we can encode a new frame */
        speex_bits_reset(&bits);
        /* Encode the frame */
        speex_encode_int(enc_state, (spx_int16_t*)IN_Buffer[Index_Encoding], &bits);
        /* Copy the bits to an array of char that can be decoded */
        speex_bits_write(&bits, (char *)Rec_Data_ptr, ENCODED_FRAME_SIZE);
          
        Rec_Data_ptr += ENCODED_FRAME_SIZE;
        Encoded_Frames += 1;
        
        Start_Encoding = 0;	
      }
    
    if (Encoded_Frames == MAX_REC_FRAMES) {        
        __no_operation(); //первая половина данных готова, можно забирать из &REC_DATA[0][0]
    }
    
    if (Encoded_Frames == MAX_REC_FRAMES*2) {
        Rec_Data_ptr = &REC_DATA[0][0];
        Encoded_Frames = 0;
        __no_operation(); //вторая половина данных готова, можно забирать из &REC_DATA[1][0]
    }
}

Также необходимо найти обработчики прерываний таймера и DMA в файле stm32f1xx_it.c и дополнить их переключением флага кодируемых данных Start_Encoding и сбросом флага таймера TIM3_IRQn:

Обработчики прерываний

void DMA1_Channel1_IRQHandler(void)
{
  /* USER CODE BEGIN DMA1_Channel1_IRQn 0 */
  if (DMA1->ISR & DMA_FLAG_HT1) { Start_Encoding = 1; } //флаг половинной готовности DMA поднят, можно кодировать первую половину
  if (DMA1->ISR & DMA_FLAG_TC1) { Start_Encoding = 2; } //флаг окончания DMA поднят, можно кодировать вторую половину половину
  /* USER CODE END DMA1_Channel1_IRQn 0 */
  HAL_DMA_IRQHandler(&hdma_adc1);
  /* USER CODE BEGIN DMA1_Channel1_IRQn 1 */

  /* USER CODE END DMA1_Channel1_IRQn 1 */
}

/**
* @brief This function handles TIM3 global interrupt.
*/
void TIM3_IRQHandler(void)
{
  /* USER CODE BEGIN TIM3_IRQn 0 */
  HAL_NVIC_ClearPendingIRQ(TIM3_IRQn);
  /* USER CODE END TIM3_IRQn 0 */
  HAL_TIM_IRQHandler(&htim3);
  /* USER CODE BEGIN TIM3_IRQn 1 */

  /* USER CODE END TIM3_IRQn 1 */
}

Таким образом вся основная программа сводится к запуску таймера и DMA, инициализации Speex и его кодирования (помимо стандартных инициализаций HAL конечно):

  Speex_Init(); 
  if(HAL_TIM_Base_Start_IT(&htim3) != HAL_OK) Error_Handler();
  if(HAL_ADC_Start_DMA(&hadc1,(uint32_t*)&IN_Buffer[0],FRAME_SIZE*2) != HAL_OK) Error_Handler();
  while (1)
  {
    EncodingVoice();
  }

А теперь немного пробегусь по коду. В функции Speex_Init инициализируется только кодировщик Speex, декодер нужно инициализировать отдельно.

Итак, мы настроили АЦП на срабатывание по триггеру таймера. Триггер таймера мы сбрасываем в прерывании каждые 0.125мс (8 кГц).

HAL_NVIC_ClearPendingIRQ(TIM3_IRQn);

По прерыванию DMA у нас происходит следующее:

if (DMA1->ISR & DMA_FLAG_HT1) { Start_Encoding = 1; }
if (DMA1->ISR & DMA_FLAG_TC1) { Start_Encoding = 2; }

Флаг DMA_FLAG_HT1 (half transfer complete) поднимается когда DMA выполнило работу на половину (читай первая половина буфера заполнена), а флаг DMA_FLAG_TC1 (transfer complete flag) соответственно, когда DMA закончило передачу (вторая половина заполнена).

Вот тут я наткнулся на интересную особенность, которую не знал и потерял на этом время. На отладчике, во время останова, DMA продолжает работать. Таким образом буфер всегда выглядит заполненным полностью и оба флага в поднятом состоянии. Нельзя так отлаживать работу DMA, оно не останавливается.

#define FRAME_SIZE 160 //*0.125мс = 20мс (сэмплирование 8кГц)
#define ENCODED_FRAME_SIZE 20 //размер выходных данных
#define MAX_REC_FRAMES 90 //максимальное число записываемых фреймов, Время = MAX_REC_FRAMES*0,02сек

Семплирование АЦП идет в двойной буфер IN_Buffer[2][FRAME_SIZE], каждая половина размером 160 сэмплов. На выходе уже получаем ENCODED_FRAME_SIZE байт данных, которые отправляются в массив REC_DATA[2][MAX_REC_FRAMES*ENCODED_FRAME_SIZE] по адресу Rec_Data_ptr. Адрес инкрементируется на ENCODED_FRAME_SIZE.

После каждого кодирования счетчик Encoded_Frames инкрементируется и в момент, когда он станет равен MAX_REC_FRAMES, первая половина выходного буфера становится полностью заполнена и можно забирать данные. На это у нас есть время, пока заполняется вторая половина, и так по кругу. Данные забираем из REC_DATA[0] и REC_DATA[1] соответственно.

Можно попробовать поиграться с рамерами фрейма, настройками качества и прочее, но я не стал.

int quality = 4, complexity=1, vbr=0, enh=1;/* SPEEX PARAMETERS, MUST REMAINED UNCHANGED */

Пример переданного звукового файла есть в репозитории первой статьи.

Материалы

1. Репозиторий с получившимся проектом на Github
2. Speex Codec Manual
3. Application Note от Silicon Labs

Автор: oWart

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js