ESP32: Interfaceamento com microfones digitais

Introdução

O ESP32 possui suporte ao protocolo I2S (Inter-IC Sound), que é um protocolo de comunicação síncrono (ou seja, precisa de clock) geralmente utilizado para comunicar áudio entre dispositivos. Apesar da proposta de lidar com áudio, a interface I2S do ESP32 é robusta o suficiente para ser utilizada com diversos propósitos, tais como usar sua infraestrutura para coletar leituras do ADC ou disponibilizar valores de tensão no DAC em alta velocidade, além de ter aplicações em displays LCD ou câmeras que necessitam de controle preciso e rápido de GPIOs, dentre outros.

O propósito deste tutorial é introduzir os conhecimentos básicos para utilizar a interface I2S em uma aplicação de interfaceamento de áudio digital no ESP32, assim estruturando um conhecimento básico em I2S para que depois possa ser aprofundado em outras aplicações.

Microfones Digitais

Existem vários tipos de microfones: eletretos, condensadores, piezoelétricos, MEMS (micro-electromechanical system), etc. O uso de cada um vai depender da aplicação e a qualidade do áudio desejada. Não é o propósito deste tutorial elencar o melhor tipo de microfone por aplicação, mas caso queira obter mais detalhes, confira o documento Microphone Handbook.

Além do tipo de construção do microfone, também existe o tipo do sinal de saída, que pode ser analógico ou digital. Um microfone com saída analógica requer circuitos adicionais para condicionamento e digitalização do sinal para que seja armazenado em formato digital. A qualidade final do áudio não vai depender somente da escolha do microfone, mas também da qualidade do circuito de condicionamento desenvolvido. Em aplicações mais simples e compactas, pode ser que não haja espaço para desenvolver esse tipo de circuito, portanto sendo necessário recorrer ao microfone digital.

Neste tutorial serão utilizados microfones digitais do tipo MEMS. Estes são ideais para aplicações que exigem uma área de aplicação pequena e são geralmente utilizados em smartphones, tablets, aplicações automotivas, industriais, médicas, etc

Microfones Digitais com ESP32
Figura 1 – Microfone MEMS. Fonte: Electronics 360.

Do tipo de saída digital, ainda existem duas ramificações possíveis, um microfone digital I2S ou PDM. Ambas as abordagens serão cobertas neste tutorial. 

Microfones Digitais com ESP32
Figura 2 – Este tutorial irá cobrir microfones MEMS digitais I2S e PDM.

Em um microfone com saída digital, todo o processo de condicionamento e digitalização do sinal de áudio é feito por circuitos integrados dentro do encapsulamento do microfone. A diferenciação entre o microfone I2S e PDM acontece no final da cadeia de digitalização. A Figuras 3 e 4 mostram o diagrama de blocos para microfones I2S e PDM, respectivamente. Ambas as arquiteturas iniciam pela captura do áudio pelo transdutor MEMS; em seguida esse sinal é amplificado (por um ou mais estágios de amplificação, mas geralmente envolvendo um amplificador de instrumentação) de uma ordem de nano ou microvolts para milivolts ou volts; depois é filtrado para limitar a banda e evitar o aliasing, no qual haverá conteúdo indesejado de alta frequência sobrepondo a banda útil do sinal, portanto a filtragem anti-aliasing atenuará as frequências acima do sinal para evitar esse efeito indesejado.

Figura 3 – Diagrama de blocos de um microfone digital I2S. Adaptado de Analog Devices.
Figura 4 – Diagrama de blocos de um microfone digital PDM. Adaptado de Analog Devices.

Nos microfones digitais I2S, o áudio filtrado é digitalizado por um conversor analógico-digital (ADC) e cada amostra é disponibilizada pela interface I2S de acordo com os requisitos do protocolo. A Figura 5a mostra o diagrama de blocos da interação entre controlador e microfone I2S. O controlador fornece duas linhas para garantir sincronismo, uma linha para o clock serial (SCK, ou serial clock, mas também pode ser conhecido como BCLK, ou bit clock e LRCLK, ou left-right clock) e outra para a seleção de palavras (WS, ou word selector/slot). O pino de seleção de canal (SEL) pode ser usado para obter áudio estéreo, em que um dos microfones possui esse pino conectado ao GND, enquanto o outro está conectado ao VDD. Os bits dos dados de áudio são fornecidos pelo microfone na linha de dados (SD, ou serial data) na velocidade do SCK, enquanto o período do WS vai delimitar uma palavra completa ou uma amostra PCM (Pulse Code Modulation). Por exemplo, se uma amostra de áudio completa possui 16 bits, então após o SCK contar os 16 bits, o WS vai mudar de nível indicando que uma palavra completa foi obtida. Se existirem dois microfones, na tentativa de coletar um áudio estéreo, o WS vai atuar alternando a coleta de dados entre um microfone e outro para que a trilha de áudio final seja uma junção do áudio entre dois microfones. A Figura 5b ilustra uma visão detalhada do diagrama temporal do I2S.

Microfones Digitais com ESP32
Figura 5 – Funcionamento da interface I2S por meio do (a) diagrama de blocos e (b) diagrama temporal. Adaptado de NXP.

Já nos microfones digitais PDM o áudio filtrado vai passar por um modulador PDM. Esse modulador é, basicamente, um ADC sigma-delta de 1 bit (cujo funcionamento pode ser visto aqui) que vai gerar um sinal PDM (Pulse Density Modulation) com alta frequência. A Figura 6 ilustra como uma senoide se compara com sua versão modulada em PDM. O sinal PDM passará mais tempo no nível alto quanto maior for a amplitude do sinal analógico original, e mais tempo no nível baixo quando menor a amplitude.

Figura 6 – Modulação PDM. Fonte: ST/AN5027.

Um ponto baixo desse tipo de microfone é que o sinal PDM obtido no controlador ainda deverá ser convertido para um sinal PCM para que seja utilizado normalmente. Também o sinal PDM requer um clock na ordem de unidades de megahertz, enquanto o I2S necessita de apenas algumas centenas de quilohertz. 

Por exemplo, imagine um sinal de áudio usando o padrão de 16-bits por amostra e 44100 amostras por segundo e mono canal. Para um microfone I2S isso significa um clock serial (SCK) de 16×44100=705600Hz=705kHz. 

Para o sinal PDM, a amostragem é um pouco diferente. A conversão de PDM para PCM se dá pela filtragem passa-baixas dos bits recebidos seguido de uma dizimação que irá reduzir a taxa do sinal PDM para a taxa final do sinal PCM desejado por um fator de sobre-amostragem. Esse fator de sobre amostragem geralmente varia entre 48 e 128. Então supondo que, durante a conversão PDM para PCM, seja usada um fator de sobre-amostragem de 64 e que é desejado que o áudio final tenha uma taxa de 44100Hz, então o clock (CLK) deverá ser 64×44100= 2822400Hz=2.8MHz (para um fator 128 teríamos 128×44100=5644800Hz=5.6MHz).

No geral, os microfones PDM são mais simples, baratos, energeticamente eficientes e compactos que os I2S. Porém os I2S possuem melhor qualidade de áudio.

Interface I2S no ESP32

Independentemente do tipo de microfone digital na sua aplicação – seja PDM ou I2S –, a interface I2S do ESP32 dará suporte para aproveitar a alta qualidade do I2S, mas também possui um módulo específico para realizar a conversão PDM para PCM para aproveitar a simplicidade desse tipo de microfone sem ter que se preocupar com os específicos dessa conversão.

O módulo receptor do PDM do ESP32 está ilustrado na Figura 7 de acordo com o que é especificado na documentação técnica do ESP32. Ele possui dois estágios de filtragem e um estágio de dizimação fixa de 8 vezes. O filter group também possui um estágio de dizimação, e este é configurável, porém limitado a dois valores: 8 e 16. Isso implica dizer que os fatores de sobre ajustes estão limitados a 64 ou 128 (multiplicação entre o 8 fixo e o 8 e 16 configurável) no ESP32. O sinal PCM resultante é de 16 bits.

Figura 7 – Módulo de conversão PDM para PCM do ESP32. Fonte: Espressif.

Porém, você deve se atentar ao tipo de ESP32 que está usando, pois a depender do modelo ele pode ou não suportar essa conversão PDM para PCM. Consulte a API da ESP-IDF para entender quais modelos possuem suporte à essa conversão e quais interfaces I2S suportam-na. O ESP32 tradicional possui duas interfaces I2S (I2S0/I2S1) e apenas a interface I2S0 suporta a recepção e conversão PDM para PCM. O modo I2S padrão (ou seja, usando o próprio protocolo I2S) é suportado em ambas as interfaces.

Um detalhe importante do I2S do ESP32 é que ele possui acesso direto à memória (DMA, Direct Memory Access), assim permitindo que o áudio seja recebido e armazenado em memória pelo controlador DMA sem a interferência da CPU. O envolvimento da CPU no processo da aquisição se resume em redirecionar os buffers de áudio para o destino apropriado, seja um cartão SD ou uma localização remota pela rede sem fio. Isso permite que as transações de dados ocorram em alta velocidade sem a necessidade de polling da CPU para realizar as leituras.

Materiais

Para a parte prática será desenvolvido um firmware padrão para coletar áudio de microfones digitais I2S e PDM e destinar o áudio por Wi-Fi para acessar pelo VLC Media Player na máquina local. A Figura 8 ilustra uma visão geral do fluxo de dados na aplicação. 

Microfones Digitais com ESP32
Figura 8 – Visão geral da arquitetura do exemplo.

Para isso serão utilizados:

  • Computador de desenvolvimento (testes realizados em uma máquina com Ubuntu 20.04);
  • ESP-IDF v5.1;
  • Kit de desenvolvimento ESP32 com cabo para programação;
  • Módulo microfone digital SPH0645LM4H MEMS I2S;
  • Módulo microfone digital MP34DT01-M MEMS PDM;

Será utilizada a versão mais recente da ESP-IDF (v5.1 até a data de escrita deste tutorial). Atenção para a versão em uso, pois a API do I2S sofreu grandes alterações do ESP-IDF v4.4 para a v5.0. Mas ainda é possível usar a API legada, ela só não será abordada neste tutorial.

A Figura 9 ilustra as placas de desenvolvimento (módulos) comercialmente distribuídas para os microfones supracitados. Note que existem os pinos de seleção de canal (SEL) e para esta aplicação, esse pino em ambos os microfones foram conectados ao GND.

Microfones Digitais com ESP32
Figura 9 – (a) Módulo microfone SPH0645LM4H e (b) módulo microfone MP34DT01-M.

Guia de programação

Para iniciar, importe as seguintes bibliotecas no código-fonte da aplicação, pois permitirão o uso de funções básicas da linguagem C e do ESP32 e acesso aos recursos do FreeRTOS:

#include <stdio.h>
#include <sys/param.h>

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

#include "esp_system.h"
#include "esp_err.h"
#include "esp_log.h"

// tags
static const char *I2S_TAG = "I2S";
static const char *UDP_TAG = "UDP";
static const char *MAIN_TAG = "MAIN";

Configurando a aquisição I2S

Para configurar a aquisição de áudio do microfone I2S (SPH0645LM4H) será necessário importar a biblioteca I2S para lidar com aquisições no modo padrão de funcionamento e a biblioteca de GPIOs:

#include "driver/i2s_std.h"
#include "driver/gpio.h"

Antes de definir a função para configuração do canal I2S é importante declarar o handler para lidar com os recursos do canal I2S a ser usado:

i2s_chan_handle_t rx_handle; // handler para canais i2s

Agora será definido o código da função void i2s_std() que será responsável por configurar o canal I2S para aquisição no modo padrão. A estrutura inicial será responsável principalmente por definir o canal I2S a ser utilizado (0 ou 1); o papel do ESP32 na transação I2S (controlador ou periférico); e as configurações do DMA no que diz respeito ao tamanho de cada buffer e a quantidade de buffers (a escolha dos valores de DMA_BUF_NUM  e DMA_BUF_SIZE  serão explicadas mais a frente). Ambos os canais I2S do ESP32 possuem suporte ao modo padrão do I2S, então a escolha é indiferente. O I2S do ESP32 atuará com o papel de controlador e o microfone com o papel de periférico (apesar da nomenclatura da API ser datada ao usar master e slave).

// configuracao do canal i2s 
i2s_chan_config_t chan_cfg = {
    .id = I2S_NUM_AUTO, // escolha do canal automatica (0 ou 1)
    .role = I2S_ROLE_MASTER, // o esp32 atua como o controlador
    .dma_desc_num = DMA_BUF_NUM, // quantidade de buffers do DMA
    .dma_frame_num = DMA_BUF_SIZE, // tamanho dos buffers do DMA
    .auto_clear = false, // limpar automaticamente o buffer TX (desnecessario)
};

Após essa configuração, use essa estrutura e o handler para canais I2S para alocar o novo canal I2S:

// alocar o canal i2s para receber dados
i2s_new_channel(&chan_cfg, NULL, &rx_handle);

Em seguida será realizada as configurações de velocidade de clock, formato das amostras de áudio e pinos a serem usados.

// configuracoes de clock, slot e gpio para o i2s no modo normal (standard)
i2s_std_config_t std_cfg = {
    .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(22050), // 22050 amostras por segundo
    .slot_cfg = I2S_STD_MSB_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_MONO), // 32bits por amostras e audio mono
    .gpio_cfg = {
        .mclk = I2S_GPIO_UNUSED, // master clock (opcional)
        .bclk = GPIO_NUM_5, // bit clock
        .ws = GPIO_NUM_18, // word slot
        .dout = I2S_GPIO_UNUSED, // data out (necessario apenas para envio de dados)
        .din = GPIO_NUM_19, // data in
        .invert_flags = { // nao inverter bits
            .mclk_inv = false,
            .bclk_inv = false,
            .ws_inv = false,
        },
    },
};

Para este exemplo será utilizado um clock tal que garanta 22050 amostras por segundo. Dentre os vários padrões de áudio (formato Philips, MSB e PCM padrão), o microfone SPH0645LM4H suporta o formato MSB que possui um padrão semelhante ao que foi apresentado no diagrama temporal da Figura 5. No momento de definir o padrão de áudio a ser utilizado, também é o momento de definir a quantidade de bits por amostra de áudio, que neste caso foi 32-bits e se a configuração de coleta de áudio é mono ou estéreo; que neste caso é mono. 

Na definição dos pinos a linha MCLK (Master Clock) não precisa ser utilizada, pois é um sinal opcional que serve para oferecer um clock de referência para um periférico I2S, que neste exemplo não será necessário. As demais linhas são conhecidas, como o BCLK, WS e DIN (Data In). O pino de DOUT (Data Out) seria necessário apenas em aplicações do tipo transmissão, em que o ESP32 enviaria áudio para uma caixa de som I2S, ou algo do tipo.

Após essas configurações basta inicializar o canal I2S no modo padrão:

// inicializar o canal i2s no modo padrao
i2s_channel_init_std_mode(rx_handle, &std_cfg);

Atenção, pois, a aquisição I2S ainda não foi iniciada. Ela será iniciada apenas na tarefa genérica para aquisição de áudio.

A Figura 10 exemplifica como as conexões entre periférico do microfone com controlador podem ser feitas para esta configuração em modo padrão do I2S.

Figura 10 – Conexão entre ESP32 e módulo microfone digital SPH0645LM4H (I2S).

Configurando a aquisição PDM

A alternativa para o modo I2S padrão é o modo PDM para garantir a aquisição de áudio do microfone MP34DT01-M. A partir da versão v5.0 do ESP-IDF as funcionalidades de cada modo I2S passou a ser dividida em diferentes arquivos, no caso do modo PDM será necessário importar as seguintes bibliotecas:

#include "driver/i2s_pdm.h"
#include "driver/gpio.h"

O handler rx_handle  deverá estar declarado previamente assim como na configuração do modo I2S padrão. O objetivo é que ele atue apenas para um tipo de aquisição por vez (I2S ou PDM). Então, ao invés de utilizar a função void i2s_std() para configurar o modo padrão do I2S, iremos definir a função void i2s_pdm() para configurar o modo PDM.

Será criada a mesma estrutura utilizada anteriormente para configurar o canal I2S. Neste caso a única diferença é a definição do canal que não poderá ser automática, mas sim deverá garantir o uso do canal 0, pois apenas este suporta o modo PDM.

// configuracao do canal i2s 
i2s_chan_config_t chan_cfg = {
    .id = I2S_NUM_0, // escolha do canal i2s 0 (no esp32 apenas esse canal suporta o modo pdm)
    .role = I2S_ROLE_MASTER, // o esp32 atua como o controlador
    .dma_desc_num = DMA_BUF_NUM, // quantidade de buffers do DMA
    .dma_frame_num = DMA_BUF_SIZE, // tamanho dos buffers do DMA
    .auto_clear = false, // limpar automaticamente o buffer TX (desnecessario)
};

Da mesma forma aloque o canal I2S:

// alocar o canal i2s para receber dados
i2s_new_channel(&chan_cfg, NULL, &rx_handle);

As configurações de clock possuirão uma certa diferença, pois agora garantem uma taxa de 44100 amostras por segundo, com amostras de 16-bits mono. O formato de amostras é, apenas, PDM RX (RX para recepção e TX para transmissão).

// configuracoes de clock, slot e gpio para o i2s no modo pdm
i2s_pdm_rx_config_t pdm_rx_cfg = {
    .clk_cfg = I2S_PDM_RX_CLK_DEFAULT_CONFIG(44100), // 44100 amostras por segundo
    .slot_cfg = I2S_PDM_RX_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO), // 16bits por amostras e audio mono
    .gpio_cfg = {
        .clk = GPIO_NUM_18, // clock
        .din = GPIO_NUM_19, // data in
        .invert_flags = { // nao inverter bits
            .clk_inv = false,
        },
    },
};

Em relação à definição dos pinos, neste caso será necessário apenas o pino de clock e de entrada de dados, que é um reflexo do quão simples esse tipo de microfone é.

Por fim inicialize o canal I2S no modo PDM:

// inicializar o canal i2s no modo pdm
i2s_channel_init_pdm_rx_mode(rx_handle, &pdm_rx_cfg);

A Figura 11 exemplifica como as conexões entre periférico do microfone com controlador podem ser feitas para esta configuração em modo PDM.

Microfones Digitais com ESP32
Figura 11 – Conexão entre ESP32 e módulo microfone digital MP34DT01-M (PDM).

Função principal (main)

Antes de explicar sobre as tarefas de aquisição e envio de áudio, é interessante passar pela função principal e como a inicialização da aplicação exemplo é feita.

No início da sua função principal, i.e. a função void app_main(void), você deverá escolher o modo de operação de aquisição, seja invocando a função void i2s_std() para o modo I2S padrão ou a função void i2s_pdm() para o modo PDM.

Nesta aplicação o envio de dados será feito usando sockets UDP, portanto será necessário realizar as configurações iniciais de rede. Neste caso será utilizada a rede Wi-Fi (que é um dos benefícios de se utilizar o ESP32). Para garantir uma configuração correta, será necessária a inclusão dos seguintes arquivos de cabeçalho:

#include "nvs_flash.h"
#include "esp_netif.h"
#include "esp_event.h"


#include "lwip/err.h"
#include "lwip/sockets.h"
#include "lwip/sys.h"

#include "protocol_examples_common.h"

Estes permitirão a configuração do Wi-Fi e lwIP (lightweight IP) que é uma pilha de protocolos TCP/IP para sistemas embarcados e seus sockets serão importantes para o envio dos dados. O componente protocol_examples_common possui funções auxiliares para facilitar o desenvolvimento de exemplos, como a função example_connect que permite estabelecer facilmente uma configuração Wi-Fi ou Ethernet (de acordo com o escolhido no menuconfig) sem maiores preocupações. Para sua inclusão, basta adicionar seu caminho no CMakeLists.txt do projeto, como abaixo:

# The following lines of boilerplate have to be in your project's
# CMakeLists in this exact order for cmake to work correctly
cmake_minimum_required(VERSION 3.16)
 
set(EXTRA_COMPONENT_DIRS $ENV{IDF_PATH}/examples/common_components/protocol_examples_common)
 
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(mic)

Ao incluir esse componente no projeto, ele trará consigo o menu “Example Connection Configuration” para o menuconfig. As configurações padrões serão suficientes para este exemplo, incluindo o fato de vir definido para usar o Wi-Fi como padrão. Além das configurações padrão será necessário apenas editar os campos WiFi SSID e WiFi Password com as credenciais da sua rede Wi-Fi (que virão com myssid e mypassword como padrão, respectivamente).

(Top) → Example Connection Configuration
                     Espressif IoT Development Framework Configuration
[*] connect using WiFi interface
[ ]     Get ssid and password from stdin
[*]     Provide wifi connect commands
(myssid) WiFi SSID
(mypassword) WiFi Password
(6)     Maximum retry
        WiFi Scan Method (All Channel) --->
        WiFi Scan threshold --->
        WiFi Connect AP Sort Method (Signal) --->
[ ] connect using Ethernet interface
[*] Obtain IPv6 address
        Preferred IPv6 Type (Local Link Address) --->

Como isso se trata de uma demonstração, não há problemas em usar esse componente para o auxílio nas configurações de rede. Porém, em uma aplicação real é ideal que essas configurações sejam realizadas com mais atenção por questões de segurança e eficiência.

Então o trecho da sua função principal para as configurações de rede deverá estar de acordo com:

ESP_ERROR_CHECK(nvs_flash_init()); // inicializar NVS
ESP_ERROR_CHECK(esp_netif_init()); // inicializar lwIP
ESP_ERROR_CHECK(esp_event_loop_create_default()); // criar event loop para eventos Wi-Fi
ESP_ERROR_CHECK(example_connect()); // funcao do protocol_examples_common.h para auxiliar na conexao Wi-Fi

As próximas configurações dirão respeito aos específicos da inicialização das tarefas e demais recursos do FreeRTOS. Mas antes disso não esqueça de declarar as seguintes variáveis globais:

QueueHandle_t xQueueData; // fila de dados e eventos para transferir leituras do microfone entre tarefas
TaskHandle_t xTaskReadDataHandle; // handler para task de leitura de dados do microfone
TaskHandle_t xTaskTransmitDataHandle; // handler para task de envio de dados

A variável xQueueData  é uma fila para ser usada na transmissão de buffers entre a tarefa de leitura de dados do microfone (cujo handle será xTaskReadDataHandle) e a tarefa de envio de dados (cujo handle será xTaskTransmitDataHandle).

Neste momento também é importante garantir que as seguintes macros estejam definidas:

#define DMA_BUF_NUM 16 // quantidade de buffers do DMA
#define DMA_BUF_SIZE 1024 // tamanho [em amostras] dos buffers do DMA
#define BUF_SIZE 4096 // tamanho [em bytes] dos buffers de entrada/transmissao

Respectivamente, elas dizem respeito a quantidade de buffers que o DMA deverá alocar; o tamanho de cada um; e o tamanho dos buffers de entrada e/ou envio de dados. No código-fonte do exemplo foram alocados 16 buffers do DMA e cada um com tamanho de 1024 amostras. Isso quer dizer que o controlador DMA irá preencher cada um desses buffers com 1024 amostras (no caso da configuração I2S padrão foram escolhidas amostras de 32-bits e no modo PDM 16-bits). Quando o buffer estiver totalmente preenchido, o controlador partirá para o próximo, e o buffer preenchido poderá ser lido sem interferir com a leitura dos demais buffers. O tamanho dos buffers de entrada e/ou envio é um valor em bytes, ou seja, ele comportará 1024 amostras de 32-bits ou 4 bytes (4096 bytes / 4 bytes por amostra = 1024 amostras) ou 2048 amostras de 16-bits ou 2 bytes (4096 bytes / 2 bytes por amostra = 2048 amostras).O próximo passo na função principal é criar a fila de tamanho DMA_BUF_NUM e tamanho dos itens de BUF_SIZE bytes para transmissão de dados entre tarefas. Em termos dos valores utilizados no exemplo, equivale dizer que ela comporta 16 buffers de entrada/envio ou 32 buffers do DMA.

xQueueData = xQueueCreate(DMA_BUF_NUM, BUF_SIZE*sizeof(char)); 
if(xQueueData == NULL){ // testar se a criacao da fila falhou
    ESP_LOGE(MAIN_TAG, "Falha em criar fila de dados");
    while(1);
}

Por fim serão criadas as tarefas para aquisição e envio dos dados de áudio:

BaseType_t xReturnedTask[2];

xReturnedTask[0] = xTaskCreatePinnedToCore(
    vTaskReadData, 
    "taskREAD", 
    configMINIMAL_STACK_SIZE+2048, 
    NULL, 
    configMAX_PRIORITIES-1, 
    &xTaskReadDataHandle, 
    APP_CPU_NUM
);


xReturnedTask[1] = xTaskCreatePinnedToCore(
    vTaskTransmitData, 
    "taskTX", 
    configMINIMAL_STACK_SIZE+2048, 
    NULL, 
    configMAX_PRIORITIES-1, 
    &xTaskTransmitDataHandle, 
    PRO_CPU_NUM
);

// testar se a criacao das tarefas falhou
if(xReturnedTask[0] == pdFAIL || xReturnedTask[1] == pdFAIL){ 
    ESP_LOGE(MAIN_TAG, "Falha em criar tarefas");
    while(1);
}

Aproveitando que o ESP32 possui dois núcleos de processamento, a tarefa de aquisição com função chamada vTaskReadData foi alocada para o núcleo de aplicação APP_CPU_NUM, enquanto a tarefa de envio com função chamada vTaskTransmitData foi alocada para o núcleo de protocolo. As tarefas foram invocadas com a maior prioridade possível e com tamanho de stack suficiente para que não haja problemas de falta de memória em tempo de execução (em uma aplicação mais complexa e que exige mais memória seria uma boa prática sintonizar o tamanho da stack de forma otimizada).

Tarefa de aquisição de áudio

A aquisição de áudio será realizada continuamente por uma tarefa do FreeRTOS. Seu código-fonte consistirá na leitura do I2S até que um buffer seja totalmente preenchido e, quando isso acontecer, o buffer será enfileirado em uma Queue do FreeRTOS para ser utilizado em uma tarefa própria para a transmissão desses dados.

O código da tarefa de leitura de dados na interface I2S se resume ao seguinte:

static void vTaskReadData(void * pvParameters)
{
    size_t bytes_to_read = BUF_SIZE; // quantidades de bytes para ler
    size_t bytes_read; // quantidade de bytes lidos
    char in_buffer[BUF_SIZE]; // buffer para entrada de dados

    // iniciar o canal i2s
    i2s_channel_enable(rx_handle);

    while (1) {
        // esperar para que o buffer de entrada (in_buffer) seja totalmente preenchido
        if (i2s_channel_read(rx_handle, (void*) in_buffer, bytes_to_read, &bytes_read, portMAX_DELAY) == ESP_OK) {
            xQueueSend(xQueueData, &in_buffer, portMAX_DELAY); // enfileirar dados lidos para a tarefa de envio
        } else {
            ESP_LOGE(I2S_TAG, "Erro durante a leitura: errno %d", errno);
            break;
        }
        vTaskDelay(1);
    }
    // no caso de erro, deve-se parar o canal i2s
    i2s_channel_disable(rx_handle);
    // liberar os recursos alocados
    i2s_del_channel(rx_handle);

    vTaskDelete(NULL);
}

Antes de entrar no loop infinito da tarefa, deverão ser declaradas as variáveis para informar a quantidade de bytes que deverão ser lidos (a quantidade foi definida como BUF_SIZE) no buffer; outra variável para rastrear quantos bytes, de fato, foram lidos; e o buffer para receber os dados lidos. Em seguida, o canal I2S será iniciado e a aquisição de dados será disparada.

Dentro do loop infinito, a função i2s_channel_read será responsável por copiar os dados lidos na interface I2S para o buffer de entrada. Lembre-se que o armazenamento de dados está sendo realizado pelo DMA e esta função será encarregada apenas de informar quando a quantidade de bytes a serem lidos foi alcançada no buffer de entrada. Seu primeiro argumento diz respeito ao handler para o canal I2S; seguido do buffer de entrada interpretado como um ponteiro genérico; a quantidade de bytes a serem lidos; a quantidade de bytes que foram lidos; e o tempo de espera para conseguir preencher a quantidade de bytes requisitada (que neste caso foi o valor máximo, implicando dizer que a função irá segurar a execução da tarefa até que a transação esteja completa).

A função de leitura está sendo chamada dentro de uma estrutura condicional, pois assim é possível verificar seu retorno e, se houver algum erro, o programa irá sair do loop infinito e se encaminhará para parar o canal I2S e liberar os recursos alocados. Caso não haja erros na leitura, a função xQueueSend será chamada para enfileirar o buffer de dados na fila xQueueData para que sejam lidos na tarefa de envio.

Tarefa de envio de áudio

Antes de configurar a tarefa de envio de áudio é importante conhecer o IP do servidor UDP da máquina de desenvolvimento e estabelecer uma porta para a aplicação (portas de valores altos geralmente são livres para uso; neste caso será utilizada a porta 9999). A máquina deverá estar conectada na mesma rede que o ESP32. 

Ao executar o comando abaixo pode-se descobrir o IP da interface de rede Wi-Fi (campo inet, que no caso é 192.168.15.8) da sua máquina de desenvolvimento que funcionará como o servidor UDP:

$ ifconfig wlan0
wlan0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.15.8  netmask 255.255.255.0  broadcast 192.168.15.255
...

Com IP e porta definidos, basta anotá-los como macros no código da aplicação:

#define SERVER_IP_ADDR "192.168.15.8" // IP do servidor UDP
#define SERVER_PORT 9999 // porta do servidor UDP

A tarefa de envio deverá ser como o trecho abaixo:

static void vTaskTransmitData(void * pvParameters)
{
    char tx_buffer[BUF_SIZE]; // buffer para envio de dados

    // configurando endereco do servidor
    struct sockaddr_in dest_addr = {
        .sin_addr.s_addr = inet_addr(SERVER_IP_ADDR),
        .sin_family = AF_INET,
        .sin_port = htons(SERVER_PORT),
    };

    while (1) {

        // criar socket UDP
        int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
        if (sock < 0) {
            ESP_LOGE(UDP_TAG, "Falha ao criar socket: errno %d", errno);
            break;
        }

    ESP_LOGI(UDP_TAG, "Socket criado. Destino dos pacotes %s:%d", SERVER_IP_ADDR, SERVER_PORT);

    while (1) {
        if(
        (xQueueData!=NULL) &&
        (xQueueReceive(xQueueData, &tx_buffer, 0)==pdTRUE) 
        ) // esperar que dados sejam lidos
        {
            // enviar buffer
            int err = sendto(
                sock, 
                tx_buffer, 
                BUF_SIZE, 
                0, 
                (struct sockaddr *)&dest_addr, 
                sizeof(dest_addr)
            );

            // avaliar se envio falhou
            if (err < 0) {
                ESP_LOGE(UDP_TAG, "Erro durante o envio: errno %d", errno);
                break;
            }
        }
        vTaskDelay(1);
    }

    if (sock != -1) {
        ESP_LOGE(UDP_TAG, "Desativando socket e reiniciando...");
        shutdown(sock, 0);
        close(sock);
        }
    } 
    vTaskDelete(NULL);
}

Fora de todos os loops será definida a estrutura responsável por configurar o endereço e porta do servidor UDP. Dentro do primeiro loop o socket UDP será criado e caso haja falha em sua criação o programa finalizará a tarefa.

No segundo loop (loop infinito principal da tarefa) haverá uma estrutura condicional que irá aguardar até que seja possível obter dados da fila xQueueData e armazenará os dados no buffer de envio (tx_buffer). Em seguida todo o conteúdo do buffer será enviado para o destino e se o envio falhar o programa irá sair do loop e desativará o socket e tentará criá-lo novamente.

Recebimento dos dados

No processo de depuração, o Netcat poderá ser usado na máquina local para conferir se os pacotes estão sendo recebidos ou até para destinar o conteúdo de áudio não processado para um arquivo:

$ nc -ulk 192.168.15.8 9999 > arquivo_de_audio

O argumento –u garante o uso de UDP, -l faz com que o comando escute por conexões e -k use outra conexão quando a atual finalizar.

Porém, ao trabalhar com áudio nada se compara com escutar o produto final. A linha de comandos do VLC Media Player permite que dados de áudio RAW sejam reproduzidos, já que nenhum tipo de codificação está sendo feita. Segue abaixo o template do comando a ser utilizado no terminal para que o VLC escute e toque os pacotes UDP:

vlc \
    --demux=rawaud \
    --rawaud-channels 1 \
    --rawaud-fourcc <FourCC> \
    --rawaud-samplerate <taxa_de_amostragem> \
    udp://@0.0.0.0:9999

Pode-se conferir que ele está configurado para receber dados em formato RAW e de apenas um canal. Também existe um campo para o FourCC (four-character code) que é um código de quatro caracteres utilizado para identificar formatos de dados (e.g. .mp3, mpeg, etc.) e a taxa de amostragem. Por fim, é definido que os dados serão recebidos pelo protocolo UDP na porta 9999 (o 0.0.0.0 é apenas uma forma de forçar que o VLC interprete o endereço como IPV4).

Para este exemplo o FourCC e a taxa de amostragem vão variar de acordo com o modo de operação do I2S (I2S padrão ou PDM) e estarão em concordância com as configurações de canal realizadas na configuração da interface I2S.Para o modo I2S padrão pode-se usar um FourCC s32l (amostras signed de 32-bits little endian) e taxa de 22050Hz:

vlc \
    --demux=rawaud \
    --rawaud-channels 1 \
    --rawaud-fourcc s32l \
    --rawaud-samplerate 22050 \
    udp://@0.0.0.0:9999

Para o modo PDM pode-se usar um FourCC s16l (amostras signed de 16-bits little endian) e taxa de 22050Hz:

vlc \
    --demux=rawaud \
    --rawaud-channels 1 \
    --rawaud-fourcc s16l \
    --rawaud-samplerate 44100 \
    udp://@0.0.0.0:9999

Isso será suficiente para reproduzir o áudio obtido de cada microfone na máquina local.

Conclusão

A partir desse artigo foi possível conhecer um pouco sobre a operação dos microfones digitais MEMS, que apesar de serem apenas um dos vários tipos de microfones, eles possuem impacto significativo nos sistemas embarcados devido sua qualidade e pequeno tamanho. Também foi possível conhecer sobre os microfones digitais de saída I2S e PDM. Finalmente foi desenvolvida uma aplicação exemplo para realizar a interface desses microfones com o ESP32, permitindo conhecer um pouco mais sobre como o microcontrolador implementa a interface I2S e como a API de I2S do ESP-IDF pode ser usada para obter um fluxo contínuo de dados do microfone e transmiti-lo via conexão sem fio.

Acesse o cõdigo completo em: https://github.com/Lwao/esp32-i2s-demo

Saiba mais

Conheça o Sonatino, uma placa de áudio compacta baseada no ESP32-S3

Usando o ULP do ESP32 em projetos Low Power

RTOS: Scheduler e Tarefas

Referências

Para mais informações, visite os links:

Imagem de capa retirada de Unsplash.

Licença Creative Commons Esta obra está licenciada com uma Licença Creative Commons Atribuição-CompartilhaIgual 4.0 Internacional.
Comentários:
Notificações
Notificar
0 Comentários
recentes
antigos mais votados
Inline Feedbacks
View all comments
Home » Software » ESP32: Interfaceamento com microfones digitais

EM DESTAQUE

WEBINARS

VEJA TAMBÉM

JUNTE-SE HOJE À COMUNIDADE EMBARCADOS

Talvez você goste: