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

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


  1. iliasam
    10.03.2017 15:50
    +2

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

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


    1. oWart
      10.03.2017 16:30

      Спасибо большое, буду знать


  1. igor_suhorukov
    11.03.2017 09:51
    +1

    Если не ошибаюсь для Speex не нужна floating point арифметика. Когда-то портировал джавную реализацию на JavaME, выдрав как зависимость кусок из Apache Harmony


  1. vitperov
    11.03.2017 11:21
    +1

    То, что CubeMx в разы упрощает жизнь я слышал давно. А сколько, если не секрет, времени заняла вся разработка с нуля?


    1. oWart
      11.03.2017 11:23

      Тяжело ответить на этот вопрос, занимался не спеша в свободное время


  1. D_EL
    11.03.2017 11:21
    +2

    Это личный проект? Бюджет на комплектующие сильно ограничен?
    Если денег не особо жалко то можно воткнуть хороший I2S микрофон, например ICS-43434.
    Это избавит от аналогового тракта и позволит достичь очень высокого качества звука.
    Изучали Вы или кто-то другой вопрос ошибки при кодировании-декодировании с помощью Speex?


    1. oWart
      11.03.2017 11:22

      Проект личный, бюджет не особо ограничен, посмотрю насчет микрофона.
      Нет, вопросы ошибок не изучал


    1. VioletGiraffe
      11.03.2017 16:13

      А что, действительно хороший микрофон, или просто удобно, что с цифровым выходом?
      $3.40 на DigiKey — это, я бы сказал, дёшево.


      1. D_EL
        11.03.2017 21:02

        Действительно хороший.
        24 bit, чувствительность -26 dB, SNR 60 dBA.
        Записывал сырой поток на частоте 24 кГц. Слышно птичек за окном, сравнивал на слух — в айфон 6 качество намного хуже.


        1. VioletGiraffe
          11.03.2017 22:17

          Любопытно, спасибо! АЧХ странноватая (резко растёт на ВЧ), но зато хоть не обрезана. И снизу только от 100 Гц слышит, но за 3.5 бакса большего хотеть странно.
          Интересно тогда вот что: почему нет нормальных недорогих USB-микрофонов? Под недорогими я подразумеваю хотя бы за $50! Их нет, хотя вот есть микрофон, STM32 F1 стоит вообще копейки и имеет нужную периферию — остаётся только программирование.


          1. D_EL
            12.03.2017 21:54

            Нет спроса — нет товара.


            1. VioletGiraffe
              12.03.2017 23:29

              Да конечно, нет спроса. Лучшим дешевым микрофоном считается Yeti за ~$120. Каждый первый ютубер им пользуется (кроме тех, у кого есть деньги на технику подороже).


  1. ubobrov
    11.03.2017 19:40

    А почему именно speex? Он же отъедает много ресурсов у контроллера. Почему, например, не G711 aLaw или uLaw? У них полоса всего 64 килобита, они табличные и ресурсы не едят.


  1. miolini
    13.03.2017 10:20

    СТС для НПИ. До 4 лет. :-)