В продолжение своей вчерашней статьи на Geektimes про Самодельный USB-свисток с микрофоном, STM32 и ESP8266 на борту хочу рассказать подробнее про реализацию оцифровки и кодирования звука на микроконтроллере STM32.
В статье покажу как настроить проект в STM32CubeMX, собирать данные с АЦП в два кольцевых буфера посредствам DMA, подключить библиотеку Speex и кодировать данные. Возможно многим материал покажется весьма очевидным, но надеюсь хоть кому-то он будет полезен.
Прошу под кат.
Что такое Speex?
Speex — это свободный кодек для сжатия речевого сигнала, который может использоваться в приложениях «голос-через-интернет» (VoIP). Сжатые кодеком Speex данные можно хранить либо в формате хранения звуковых данных Ogg, либо передавать напрямую с помощью пакетов UDP/RTP. © Wiki
Про Speex я узнал из статьи Распознавание речи на STM32F4-Discovery, советую почитать, большая часть кода взята оттуда.
Элементная база
В статье я буду использовать самую дешевую и распространенную отладочную плату на базе микроконтроллера STM32F103C8T6. К ней должен быть отдельно приобретен программатор. Подход не изменится и для любой платы Discovery. К отладке я подключал микрофонный модуль с усилителем Max9812.
Схему можно посмотреть в статье, указанной в самом начале. Там я завожу на АЦП сигнал прям с выхода Max9812. Для этого на покупном модуле нужно закоротить конденсатор на ноге OUT (Пятой точкой чувствую, что так делать нельзя, но не знаю как правильно). По входу получается сигнал с постоянной составляющей ~1,6V. Его мы отснимаем и в программе приводим к знаковому типу для выполнения кодирования.
Настройка проекта в STM32CubeMX
Создадим новый проект с микроконтроллером STM32F103C8T6. Первым делом указываем, что у нас подключен внешний кварцевый резонатор. Часовой кварц нам сейчас не нужен, хотя на отладочной плате он тоже есть. Не забываем включить интерфейс отладки Serial Wire. Потом включаем необходимый вход АЦП, у меня это IN8 (см. схему в предыдущей статье). Ну и удобный таймер, по которому DMA будет забирать данные из буфера.
После этого заходим во вкладку Clock Configuration и настраиваем схему тактирования. У меня получилось так:
Я задал частоту для основной периферии микроконтроллера по максимуму в 72 МГц. На Таймеры тоже заведено 72 МГц, запомним это значение. Вы можете сделать по-другому, но тогда и таймер надо будет пересчитать по-своему.
Переходим во вкладку Configuration. Тут нам надо настроить АЦП, DMA и Таймер.
АЦП настраиваем по триггеру таймера 3. Тут же во вкладке DMA выделяем под это первый канал DMA Peripheral To Memory (из переферии в память). Приоритет не важен, если в программе больше ничего нет. Режим — Circular (циклический), размер данных Half Word (полслова, 2 байта) и адрес памяти будет инкрементироваться.
Далее настроим таймер. Speex поддерживает кодирование данные в узкой полосе частот (Narrowband, 8 кГц), широкой (wideband, 16 кГц) и ультраширокой (ultra-wideband, 32 кГц). Не будем нагружать контроллер, возьмем по минимуму. Получается контроллер должен отснимать данные с АЦП на частоте 8 кГц. На таймер нам приходит 72 МГц. Считаем:
Настраиваем таймер на значение 8999 (считать ведь он начинает с нуля) и событие по таймеру Update Event. Ставим галочку глобального прерывания.
Можно переходить к генерации проекта. Заходим в Project → Serrings. Укажем путь сохранения проекта и размеры стэка и кучи. Для кодирования Speex нам примерно понадобится 0x600 и 0x1600. После этого генерируем для своей среды и открываем, у меня это IAR.
Подключим библиотеку 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 сразу:
#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);
#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