В продолжение своей вчерашней статьи на Geektimes хочу рассказать подробнее про реализацию оцифровки и кодирования звука на микроконтроллере 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 сразу:
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
