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

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 нам примерно понадобится 0×600 и 0×1600. После этого генерируем для своей среды и открываем, у меня это 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 
#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


Также необходимо найти обработчики прерываний таймера и 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

Комментарии (1)

  • 10 марта 2017 в 15:50

    0

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

    Я думаю, вам поможет команда »__HAL_DBGMCU_FREEZE_TIM3()»
    После ее вызова при входе в режим отладки TIM3 автоматически остановится, при переходе в обычный режим работы — вновь запустится. Немного подробнее: ссылка.

© Habrahabr.ru