Neste tutorial, vais aprender a criar uma aplicação de controlo por voz com a placa XIAO-ESP32-S3-Sense e a plataforma Edge Impulse. Primeiro, vamos recolher dados de áudio com o microfone da XIAO-ESP32-S3-Sense. Depois, vamos carregar esses dados de áudio para a plataforma Edge Impulse e treinar um modelo de deteção de palavras-chave. Finalmente, vamos implementar este modelo TinyML na XIAO-ESP32-S3-Sense, o que nos permitirá controlar LEDs com a nossa voz.
O modelo TinyML será treinado para reconhecer as palavras “red”, “yellow” e “green”. Vamos ligar três LEDs (vermelho, amarelo, verde) à placa XIAO-ESP32-S3-Sense, que poderás ligar através de comandos de voz. Vê o sistema em ação abaixo:
Se ainda não usaste a XIAO-ESP32-S3 Sense, dá uma vista de olhos ao Getting started with XIAO-ESP32-S3-Sensetutorial primeiro, caso contrário, vamos começar…
Peças Necessárias
Vais precisar de uma placa XIAO ESP32 S3 Sense para experimentar os exemplos de código. Nota que a placa pode aquecer e talvez queiras colocar um pequeno Heatsink na parte de trás (ver a peça listada abaixo).
Depois, vais precisar de um cartão SD para armazenar as amostras de áudio recolhidas. Indiquei um cartão de 32 GB, mas um mais pequeno (8GB) também serve. E, finalmente, se o teu computador não tiver leitor de cartão SD incorporado, vais precisar de um leitor externo.

Seeed Studio XIAO ESP32 S3 Sense

Cabo USB C

Pequeno dissipador 9×9 mm

Cartão SD 32GB

Leitor de Cartão SD
Makerguides is a participant in affiliate advertising programs designed to provide a means for sites to earn advertising fees by linking to Amazon, AliExpress, Elecrow, and other sites. As an Affiliate we may earn from qualifying purchases.
A placa XIAO-ESP32-S3-Sense
A placa XIAO-ESP32-S3-Sensebaseia-se no ESP32-S3, um chip da Espressif concebido para tarefas de IA e computação de borda. A placa tem quatro partes: a placa principal, o Sense Hat, a câmara e uma antena Wi-Fi externa:

O Sense Hat pode ser ligado à placa principal e tem um soquete para cartão SD, um soquete para a câmara e um microfone. A imagem abaixo mostra a placa XIAO-ESP32-S3-Sense completamente montada com um cartão SD inserido:

No entanto, para este projeto não precisaremos da antena WiFi nem da câmara. Portanto, a configuração mostrada abaixo é suficiente e nem precisarás do cartão SD, depois de recolheres as amostras de áudio.

Nota que a alimentação da placa pode ser feita pela porta USB Tipo-C ou pela interface de carregamento da bateria, que pode ser ligada a uma bateria LiPo de 3,7V.
Microfone da XIAO-ESP32-S3-Sense
A XIAO-ESP32-S3-Sense vem com um MEMS Microphone digital incorporado do tipo MSM261D3526H1CPM que está localizado no Sense Hat. Vê a imagem abaixo:

Podes comunicar com o microfone através de duas linhas de sinal (PDM_CLK, PDM_DATA) via I2S protocol que estão ligadas a IO42 e IO41 conforme mostrado no esquema abaixo:

Para informações mais detalhadas, vê o nosso Record Audio with XIAO-ESP32-S3-Sense tutorial.
Pinout da placa XIAO-ESP32-S3-Sense
Finalmente, vamos ver o pinout da placa XIAO-ESP32-S3-Sense:

O pino 5V é o 5V da porta USB. O pino 3V3 fornece a saída do regulador onboard e pode fornecer até 700mA. O pino GND é o terra.
Quanto aos GPIOs: a placa oferece 11 GPIOs digitais/analógicos, mas GPIO0, GPIO3, GPIO43 e GPIO44 são pinos de configuração que precisam estar num estado específico durante o arranque – por isso, tem cuidado com estes. Depois de o microcontrolador estar a funcionar, estes pinos operam como pinos IO normais.
Se precisares de mais ajuda, vê o Getting started with XIAO-ESP32-S3-Sense tutorial.
Formatar Cartão SD para Recolha de Áudio
Antes de começarmos a recolher amostras de áudio para treinar o nosso modelo TinyML, precisamos garantir que o cartão SD está formatado corretamente.
Insere o cartão SD no leitor de cartão SD, que pode estar incorporado no teu computador ou usar o leitor externo listado em Peças Necessárias.
Depois abre o Explorador (no Windows), procura uma nova drive USB e faz clique direito para abrir o menu da drive. Seleciona “Mostrar mais opções” e depois “Formatar…” para abrir o diálogo de formatação:

Verifica se o sistema de ficheiros está definido para “FAT32” e pressiona “Iniciar”. Certifica-te de que selecionaste a drive USB correta, pois a formatação apagará todos os dados existentes nessa drive!
Normalmente dou um nome novo à drive, por exemplo “SAMPLES”, para garantir que estou a formatar a drive correta e não outra por engano.
Recolha de Amostras de Áudio com XIAO-ESP32-S3-Sense
Agora vamos escrever o código para recolher as amostras de áudio necessárias para treinar o nosso modelo TinyML para controlo por voz. Certifica-te de que formataste e inseriste o cartão SD corretamente na XIAO-ESP32-S3-Sense, como mostrado abaixo:

Queremos usar a nossa voz para ligar um LED vermelho, amarelo ou verde. As palavras de controlo serão “red”, “yellow” e “green”, embora possas escolher outras palavras em qualquer idioma que queiras.

Poderias recolher essas amostras de áudio usando o teu telemóvel ou o microfone do computador, mas as características (som) desses microfones serão diferentes do microfone da XIAO-ESP32-S3-Sense. Por isso, é melhor usar o microfone da XIAO-ESP32-S3-Sense, o que requer algum código para gravar amostras de áudio e armazená-las no cartão SD.
O código seguinte recolhe amostras de áudio. Tens de inserir a etiqueta (“red”, “yellow” ou “green”) no Monitor Serial e pressionar Enter.

Isto inicia uma gravação de 1 segundo, onde dizes a palavra de controlo (por exemplo, “red”). A gravação é então guardada no cartão SD como uma amostra de áudio. Dá uma vista rápida ao código completo primeiro e depois discutimos os detalhes:
#include "ESP_I2S.h"
#include "FS.h"
#include "SD.h"
I2SClass i2s;
void scaleVolume(int16_t* audioData, size_t sampleCount) {
const float gain = 16.0;
for (size_t i = 0; i < sampleCount; i++) {
audioData[i] = (int16_t)constrain(audioData[i] * gain, INT16_MIN, INT16_MAX);
}
}
void setup() {
Serial.begin(115200);
while (!Serial) {
delay(10);
}
i2s.setPinsPdmRx(42, 41);
if (!i2s.begin(I2S_MODE_PDM_RX, 16000, I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO)) {
Serial.println("Can't find microphone!");
}
if (!SD.begin(21)) {
Serial.println("Failed to mount SD Card!");
}
Serial.println("Enter a label and press Enter to record 1 second of audio:");
}
void loop() {
static int cnt = 1;
static char filename[64];
static String label = "audio";
if (Serial.available() > 0) {
String entered = Serial.readStringUntil('\n');
entered.trim();
if (entered.length() > 0) {
label = entered;
cnt = 1;
String folder = "/" + label;
if (!SD.exists(folder)) {
SD.mkdir(folder);
}
}
sprintf(filename, "/%s/%s_%d.wav", label.c_str(), label.c_str(), cnt++);
Serial.printf("Recording to: %s\n", filename);
uint8_t* wav_buffer;
size_t wav_size;
wav_buffer = i2s.recordWAV(1, &wav_size);
if (wav_size > 44) {
size_t sampleCount = (wav_size - 44) / 2; // 16-bit samples = 2 bytes per sample
scaleVolume((int16_t*)(wav_buffer + 44), sampleCount);
}
File file = SD.open(filename, FILE_WRITE);
if (!file) {
Serial.println("Failed to open file for writing!");
return;
}
if (file.write(wav_buffer, wav_size) != wav_size) {
Serial.println("Failed to write audio data to file!");
return;
}
file.close();
Serial.println("done.");
}
delay(100);
}
O primeiro passo deste programa é incluir as bibliotecas necessárias. Estas bibliotecas permitem que o ESP32 use a interface I2S para entrada do microfone e para armazenar ficheiros num cartão SD. Sem elas, o ESP32 não conseguiria gravar áudio nem guardá-lo.
#include "ESP_I2S.h" #include "FS.h" #include "SD.h"
Constantes
O código define uma constante para o tamanho do cabeçalho do ficheiro WAV. Todo ficheiro WAV começa com um cabeçalho de 44 bytes que contém informações como taxa de amostragem, profundidade de bits e número de canais. Definindo header_size, o código sabe onde começam os dados reais de áudio.
const int header_size = 44;
Objetos
De seguida, o programa cria um objeto I2SClass. Este objeto gere toda a comunicação com o microfone usando o protocolo I2S. Criando esta instância, o ESP32 pode inicializar o microfone, definir os seus parâmetros e gravar áudio.
I2SClass i2s;
scaleVolume
A função scaleVolume() ajusta o volume do áudio gravado. Recebe amostras de áudio brutas, multiplica-as por um fator de ganho e depois limita os valores para caberem no intervalo de áudio de 16 bits. Isto garante que o som não distorce devido a overflow.
void scaleVolume(int16_t* audioData, size_t sampleCount) {
const float gain = 16.0;
for (size_t i = 0; i < sampleCount; i++) {
audioData[i] = (int16_t)constrain(audioData[i] * gain, INT16_MIN, INT16_MAX);
}
}
Poderias também normalizar o volume (o fator de escala típico que observei foi 20x), mas isso não funciona com streaming e amplifica o ruído. Mas é útil para ter uma ideia de um fator de ganho adequado, por exemplo 8x ou 16x.
void normalizeVolume(int16_t* audioData, size_t sampleCount) {
float maxAmplitude = 0;
for (size_t i = 0; i < sampleCount; i++) {
int16_t absValue = abs(audioData[i]);
if (absValue > maxAmplitude) {
maxAmplitude = absValue;
}
}
float scaleFactor = (float)INT16_MAX / (maxAmplitude + 1);
Serial.printf("scaleFactor %f\n", scaleFactor);
for (size_t i = 0; i < sampleCount; i++) {
float scaledSample = (float)audioData[i] * scaleFactor;
audioData[i] = (int16_t)constrain(scaledSample, INT16_MIN, INT16_MAX);
}
}
setup
A função setup() prepara tudo antes do loop principal correr. Primeiro, inicializa o monitor serial para depuração. Depois configura os pinos do microfone com i2s.setPinsPdmRx(42, 41) e inicia a interface I2S a 16 kHz em modo mono. Se não for detetado microfone, imprime uma mensagem de erro. Depois, o cartão SD é inicializado no pino 21. Se o cartão SD falhar a montar, outra mensagem de erro é impressa. Finalmente, o ESP32 pede ao utilizador para inserir uma etiqueta através do monitor serial.
void setup() {
Serial.begin(115200);
while (!Serial) {
delay(10);
}
i2s.setPinsPdmRx(42, 41);
if (!i2s.begin(I2S_MODE_PDM_RX, 16000, I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO)) {
Serial.println("Can't find microphone!");
}
if (!SD.begin(21)) {
Serial.println("Failed to mount SD Card!");
}
Serial.println("Enter a label and press Enter to record 1 second of audio:");
}
loop
Dentro do loop(), o programa espera por entrada do utilizador no monitor serial. O utilizador pode escrever uma etiqueta, como “red” ou “noise”. Quando uma etiqueta é inserida, o código cria uma pasta no cartão SD com esse nome. Esta organização facilita separar as gravações e simplifica o upload para a plataforma Edge Impulse.
if (Serial.available() > 0) {
String entered = Serial.readStringUntil('\n');
entered.trim();
if (entered.length() > 0) {
label = entered;
cnt = 1;
String folder = "/" + label;
if (!SD.exists(folder)) {
SD.mkdir(folder);
}
}
Depois de definir a etiqueta, o programa gera um nome de ficheiro para a nova gravação. Usa o formato label/label_number.wav. Por exemplo, se a etiqueta for “red”, o primeiro ficheiro será red/red_1.wav. Cada nova gravação incrementa o contador, para que as gravações sejam guardadas por ordem sem sobrescrever.
sprintf(filename, "/%s/%s_%d.wav", label.c_str(), label.c_str(), cnt++);
Serial.printf("Recording to: %s\n", filename);
Para gravar áudio, o código chama i2s.recordWAV(1, &wav_size). Esta função captura um segundo de áudio e armazena-o na memória como um ficheiro WAV. Se a gravação contiver dados de áudio válidos, o programa aplica a função scaleVolume() para aumentar o ganho das amostras de áudio.
wav_buffer = i2s.recordWAV(1, &wav_size);
if (wav_size > header_size) {
size_t sampleCount = (wav_size - header_size) / 2;
scaleVolume((int16_t*)(wav_buffer + header_size), sampleCount);
}
Depois de processar o áudio, o programa abre um ficheiro no cartão SD e escreve os dados WAV nele. Se o ficheiro não puder ser aberto ou escrito, imprime uma mensagem de erro. Depois de guardar a gravação, o ficheiro é fechado e uma mensagem de confirmação é impressa.
File file = SD.open(filename, FILE_WRITE);
if (!file) {
Serial.println("Failed to open file for writing!");
return;
}
if (file.write(wav_buffer, wav_size) != wav_size) {
Serial.println("Failed to write audio data to file!");
return;
}
file.close();
Serial.println("done.");
Finalmente, o loop espera 100 milissegundos antes de verificar novamente por nova entrada do utilizador. Este pequeno atraso evita que o ESP32 sobrecarregue o processamento da entrada serial.
delay(100);
Este projeto transforma o teu ESP32 num gravador de áudio prático que pode capturar clipes de um segundo de um microfone I2S e guardá-los como ficheiros WAV num cartão SD. Com pastas etiquetadas, torna-se especialmente útil para construir conjuntos de dados para projetos de machine learning, como treinar um modelo de reconhecimento de voz.
Executar o Código para Recolher Amostras
Faz flash da tua XIAO-ESP32-S3-Sense com o código e abre o monitor serial. Insere a etiqueta para a classe que queres recolher amostras na caixa de mensagem, por exemplo “yellow” e depois pressiona Enter.

O Monitor Serial mostrará o nome do ficheiro para a amostra de áudio, por exemplo “/yellow/yellow_1.wav” e tens agora um segundo para dizer a palavra “yellow”. Quando terminares, o texto “done.” será impresso no Monitor Serial. Abaixo um exemplo de uma amostra de áudio gravada para a palavra “yellow”:
Para gravações subsequentes da mesma classe, por exemplo “yellow”, basta pressionar Enter. Não insiras a mesma etiqueta de classe novamente. Se estiveres pronto para gravar as amostras para a próxima classe, por exemplo “red”, insere “red”, pressiona Enter e continua a gravar da mesma forma.
O código criará uma nova pasta para cada classe (“red”, “green”, “yellow”, “noise”) e armazenará as amostras de áudio dentro dessa pasta:

Gravei 50 amostras por cor e 100 amostras para ruído. Para a classe “noise”: grava silêncio, ruído ambiental, música, fala, tudo exceto as palavras “red”, “green” ou “yellow”. Quanto mais amostras tiveres para cada classe, melhor, mas com 50 amostras já se obtém uma precisão de deteção razoável – não ótima, mas razoável ; )
Treinar Modelo de Controlo por Voz usando Edge Impulse
Nesta secção, vamos passar pelos passos necessários para carregar os nossos dados de treino, criar o pipeline de pré-processamento de dados e modelo (impulse) e treinar o nosso modelo TinyML.
Cria um novo projeto Edge Impulse, por exemplo, com o nome “Voice Control” e depois estamos prontos para carregar os nossos dados de treino.
Carregar Dados de Treino
Primeiro, remove o cartão SD da XIAO-ESP32-S3-Sense e liga-o ao teu computador, para que possamos carregar dados dele. Ele aparecerá no teu computador como uma drive USB, se usares um leitor de cartão SD externo.
Depois clica em “Data acquisition” -> “+ Add data” -> “Upload data” para abrir o diálogo de carregamento de dados:

No diálogo, marca “Select a folder” e insere a etiqueta para a classe que queres carregar, por exemplo “red”, na parte inferior:

Agora clica em “Choose files” e seleciona a pasta “red” com a tua amostra de áudio. Depois clica em “Upload data” e deverás ver os dados a serem carregados:

Repete este processo para as outras cores e para a classe de ruído. Certifica-te de inserir a etiqueta correta em “Enter label” antes de carregar. No entanto, podes depois apagar amostras que carregaste por engano com a etiqueta errada.
Quando terminares, fecha o diálogo pressionando x no canto superior direito:

Explorar Dados
Deverás então ver o conjunto de dados no Data explorer. No canto superior esquerdo verás um gráfico de pizza, representando a distribuição das tuas classes e a divisão Train/Test. Abaixo está o Dataset, onde podes clicar em amostras individuais para as ouvir:

Se achares que há algo errado com os teus dados, existem também funções para apagar amostras. Mas se estiveres satisfeito, o próximo passo é criar um Impulse (Pré-processamento de dados + Modelo).
Criar Impulse
Clica em “Create impulse” em “Impulse design”:

Depois adiciona três blocos: “Time series data”, “Audio (MFE)” e “Transfer Learning (Keyword Spotting)”:

Mantém todas as definições padrão dos blocos como estão. Nas secções seguintes configuramos e treinamos os blocos individuais.
MFE
Clica em “MFE” em “Impulse design”:

Isto abrirá o diálogo de definições para o bloco MFE:

MFE significa Mel Frequency Energy e é um método de processamento digital de sinal para extrair características de um sinal de áudio. Mantemos as definições do MFE como estão. Apenas pressiona “Save parameters” para as guardar.
No Feature explorer poderás então ver quão bem estas características extraídas funcionam:

Cada ponto no gráfico acima representa uma das nossas amostras de áudio (uma palavra falada ou ruído). Idealmente, queres que os pontos da mesma classe estejam agrupados e que os grupos estejam claramente separados.
O gráfico acima mostra algum agrupamento, mas não é ótimo. Podes ver que os grupos para as palavras de cor (“red”, “green”, “yellow”) estão espalhados. Provavelmente porque varie a minha distância ao microfone ao falar.
A classe “noise”, por outro lado, tem forma circular, pois a maioria das amostras de ruído são relativamente semelhantes em volume, mas diferentes no conteúdo.
Com base neste agrupamento, não esperaria uma precisão fantástica de reconhecimento. Podes experimentar as definições do bloco MFE e recolher mais amostras, o que deverá tornar o modelo mais robusto.
Transfer Learning
Para treinar o modelo, clica em “Transfer learning” em “Impulse design”:

Isto abrirá o diálogo de definições da Rede Neural mostrado abaixo:

Podes escolher entre dois modelos: MobileNetV1 0.1 ou MobileNetV2 0.35. O segundo é provavelmente mais preciso, mas também muito maior e não consegui fazê-lo funcionar na XIAO-ESP32-S3-Sense. Por isso, escolhi o modelo MobileNetV1 0.1.

Aumentei o número de ciclos de treino para 60, defini a taxa de aprendizagem para 0.01 e mantive a CPU como processador de treino. No entanto, nenhum destes parâmetros é geralmente muito sensível ou importante para a precisão final do modelo e as definições padrão também funcionam bem.
Depois clica em “Save & Train”, que irá treinar o modelo. No final do processo de treino, verás uma matriz de confusão e algumas outras métricas de desempenho da rede impressas:

Como podes ver, no meu caso a precisão global foi de 95% e o modelo tendia a confundir palavras de cor com ruído, o que é expectável. A precisão provavelmente poderia ser melhorada com mais amostras de treino, mas é um modelo muito pequeno e a sua capacidade de reconhecimento é limitada.
Implementar Modelo
Finalmente, precisamos de implementar o modelo para o executar na XIAO-ESP32-S3-Sense. Clica em “Deployment” em “Impulse design”:

No canto superior direito do ecrã podes selecionar o processador alvo para a implementação. Em agosto de 2025, o Edge Impulse não suporta diretamente a XIAO-ESP32-S3-Sense. Em vez disso, selecionamos o Espressif ESP-EYE:

Se clicares nele, abrirá o diálogo de configuração para o dispositivo alvo:

Mais tarde vamos corrigir o código implementado para funcionar com a XIAO-ESP32-S3-Sense. Para o tipo de implementação selecionamos “Arduino library” e “TensorFlow Lite”:

Depois clica no botão de build, para construir e implementar o modelo:

Irá descarregar o modelo como ficheiro ZIP, no meu caso: “ei-voice-control-arduino-1.0.8.zip“. O nome do ficheiro ZIP dependerá do nome que escolheste para o projeto Edge Impulse (“Voice Control”).

Para usar a biblioteca, cria um novo Sketch vazio e depois instala a biblioteca descarregada (ei-voice-...zip) como habitual no Arduino IDE via Sketch -> Include Library -> Add .ZIP library.
Ligar LEDs à XIAO-ESP32-S3-Sense
Antes de escrever o código para controlar os LEDs, mostro rapidamente como ligar os três LEDs à XIAO-ESP32-S3-Sense:

O cátodo dos três LEDs está ligado ao terra (GND). Os ânodos dos LEDs estão ligados aos pinos GPIO 1, 2 e 3 através de um resistor de 220Ω, como mostrado acima. Podes escolher outros pinos GPIO, mas se o fizeres, certifica-te de ajustar o código seguinte.
Código para Controlo por Voz dos LEDs
A biblioteca ei-voice-control-arduino-1.0.8.zip que implementámos vem com algum código de exemplo, mas não podemos usá-lo, pois é para o ESP-EYE, que tem uma interface de microfone diferente da XIAO-ESP32-S3-Sense.
Por isso, peguei no código de exemplo e alterei-o para funcionar com a XIAO-ESP32-S3-Sense. E adicionei o código para ligar os LEDs.
O código seguinte escuta o microfone, envia o sinal de áudio gravado para o modelo classificador, obtém o resultado da classificação e pisca brevemente o LED vermelho, amarelo ou verde, se uma palavra de cor for reconhecida com confiança suficiente.
Dá uma vista rápida ao código completo primeiro, e depois discutimos os seus detalhes. Certifica-te de substituir a importação #include "Voice_control_inferencing.h" pelo nome da tua biblioteca implementada!
#include "Voice_control_inferencing.h"
#include "ESP_I2S.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
typedef struct {
int16_t *buffer;
uint8_t buf_ready;
uint32_t buf_count;
uint32_t n_samples;
} inference_t;
static inference_t inference;
static const uint32_t sample_buffer_size = 2048;
static signed short sampleBuffer[sample_buffer_size];
static bool debug_nn = false;
static bool record_status = true;
static const int8_t I2S_CLK = 42;
static const int8_t I2S_DIN = 41;
static const uint32_t SAMPLERATE = 16000;
static const int8_t redPin = 1;
static const int8_t yellowPin = 2;
static const int8_t greenPin = 3;
I2SClass I2S;
static void audio_inference_callback(uint32_t n_samples) {
for (uint32_t i = 0; i < n_samples; i++) {
inference.buffer[inference.buf_count++] = sampleBuffer[i];
if (inference.buf_count >= inference.n_samples) {
inference.buf_count = 0;
inference.buf_ready = 1;
}
}
}
static void capture_samples(void *arg) {
const float gain = 16.0;
const uint32_t n_samples_to_read = (uint32_t)arg;
while (record_status) {
for (uint32_t i = 0; i < n_samples_to_read; i++) {
int16_t sample = I2S.read();
sampleBuffer[i] = (int16_t)constrain(sample * gain, INT16_MIN, INT16_MAX);
}
if (record_status) {
audio_inference_callback(n_samples_to_read);
}
}
vTaskDelete(NULL);
}
static bool microphone_inference_start(uint32_t n_samples) {
inference.buffer = (int16_t *)malloc(n_samples * sizeof(int16_t));
if (!inference.buffer) return false;
inference.buf_count = 0;
inference.n_samples = n_samples;
inference.buf_ready = 0;
I2S.setPinsPdmRx(I2S_CLK, I2S_DIN);
if (!I2S.begin(I2S_MODE_PDM_RX, SAMPLERATE, I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO)) {
ei_printf("Can't find microphone!\r\n");
return false;
}
record_status = true;
xTaskCreate(capture_samples, "CaptureSamples", 1024 * 32, (void *)sample_buffer_size, 10, NULL);
return true;
}
static bool microphone_inference_record(void) {
while (inference.buf_ready == 0) delay(10);
inference.buf_ready = 0;
return true;
}
static int microphone_audio_signal_get_data(size_t offset, size_t length, float *out_ptr) {
numpy::int16_to_float(&inference.buffer[offset], out_ptr, length);
return 0;
}
static void microphone_inference_end(void) {
I2S.end();
free(inference.buffer);
}
void initLEDs() {
pinMode(redPin, OUTPUT);
pinMode(yellowPin, OUTPUT);
pinMode(greenPin, OUTPUT);
}
void switchOffLEDs() {
digitalWrite(redPin, LOW);
digitalWrite(yellowPin, LOW);
digitalWrite(greenPin, LOW);
}
void flashLED(const char* label) {
switchOffLEDs();
if(!strcmp("red", label)) {
digitalWrite(redPin, HIGH);
}
if(!strcmp("yellow", label)) {
digitalWrite(yellowPin, HIGH);
}
if(!strcmp("green", label)) {
digitalWrite(greenPin, HIGH);
}
delay(500);
switchOffLEDs();
}
void setup() {
Serial.begin(115200);
initLEDs();
if (!microphone_inference_start(EI_CLASSIFIER_RAW_SAMPLE_COUNT)) {
ei_printf("ERR: Could not allocate audio buffer\r\n");
return;
}
ei_printf("Listening...\n");
}
void loop() {
if (!microphone_inference_record()) {
ei_printf("ERR: Failed to record audio...\n");
return;
}
signal_t signal;
signal.total_length = EI_CLASSIFIER_RAW_SAMPLE_COUNT;
signal.get_data = µphone_audio_signal_get_data;
ei_impulse_result_t result = { 0 };
EI_IMPULSE_ERROR r = run_classifier(&signal, &result, debug_nn);
if (r != EI_IMPULSE_OK) {
ei_printf("ERR: Failed to run classifier (%d)\n", r);
return;
}
float max_val = -1.0;
int max_idx = -1;
auto cl = result.classification;
for (size_t ix = 0; ix < EI_CLASSIFIER_LABEL_COUNT; ix++) {
if (cl[ix].value > max_val && strcmp("noise", cl[ix].label)) {
max_val = cl[ix].value;
max_idx = ix;
}
}
if (max_idx >= 0 && max_val > 0.3) {
ei_printf("Predicted label: %s (%.3f)\n", cl[max_idx].label, max_val);
flashLED(cl[max_idx].label);
}
}
Importações
As primeiras linhas trazem as bibliotecas que precisamos. Cada uma tem um papel importante no reconhecimento de voz e controlo do hardware.
#include "Voice_control_inferencing.h" #include "ESP_I2S.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h"
O ficheiro Voice_control_inferencing.h é gerado pelo Edge Impulse e contém a rede neural treinada. Como mencionado antes, este nome dependerá do nome do teu projeto Edge Impulse e da biblioteca implementada. Podes ter de ajustar isto.
A biblioteca ESP_I2S.h fornece acesso à interface do microfone I2S e os cabeçalhos FreeRTOS permitem correr a captura de áudio como uma tarefa em segundo plano.
Constantes e Buffers
De seguida definimos estruturas e constantes para gerir a gravação e classificação de áudio.
typedef struct {
int16_t *buffer;
uint8_t buf_ready;
uint32_t buf_count;
uint32_t n_samples;
} inference_t;
static inference_t inference;
static const uint32_t sample_buffer_size = 2048;
static signed short sampleBuffer[sample_buffer_size];
static bool debug_nn = false;
static bool record_status = true;
A estrutura inference_t guarda o buffer de áudio e controla quantas amostras temos. Alocamos sampleBuffer como espaço temporário para a entrada do microfone. Enquanto a flag record_status controla se continuamos a gravar.
Pinos do Microfone e Taxa de Amostragem
Definimos então quais os pinos do ESP32 ligados ao microfone e a velocidade de amostragem.
static const int8_t I2S_CLK = 42; static const int8_t I2S_DIN = 41; static const uint32_t SAMPLERATE = 16000;
O microfone liga-se via I2S. O pino 42 fornece o sinal de relógio, e o pino 41 é a entrada de dados. Amostramos a 16 kHz, ideal para reconhecimento de voz.
Pinos dos LEDs
Atribuímos três pinos para feedback visual.
static const int8_t redPin = 1; static const int8_t yellowPin = 2; static const int8_t greenPin = 3;
Cada pino controla um LED. Eles piscam quando o classificador deteta a palavra de cor correspondente.
Callback de Inferência de Áudio
Esta função move amostras do buffer temporário para o buffer principal de inferência.
static void audio_inference_callback(uint32_t n_samples) {
for (uint32_t i = 0; i < n_samples; i++) {
inference.buffer[inference.buf_count++] = sampleBuffer[i];
if (inference.buf_count >= inference.n_samples) {
inference.buf_count = 0;
inference.buf_ready = 1;
}
}
}
Quando o buffer enche, marcamos como pronto para que o classificador o use.
Captura de Amostras do Microfone
Em vez de gravar áudio no loop principal, corremos esta tarefa em segundo plano.
static void capture_samples(void *arg) {
const float gain = 16.0;
const uint32_t n_samples_to_read = (uint32_t)arg;
while (record_status) {
for (uint32_t i = 0; i < n_samples_to_read; i++) {
int16_t sample = I2S.read();
sampleBuffer[i] = (int16_t)constrain(sample * gain, INT16_MIN, INT16_MAX);
}
if (record_status) {
audio_inference_callback(n_samples_to_read);
}
}
vTaskDelete(NULL);
}
Lemos continuamente amostras do microfone usando I2S.read(). Cada amostra é amplificada com um fator de ganho antes de ser armazenada. Quando recolhemos amostras suficientes, passamos para inferência.
Iniciar Inferência do Microfone
Esta função inicializa o microfone e inicia a tarefa de captura.
static bool microphone_inference_start(uint32_t n_samples) {
inference.buffer = (int16_t *)malloc(n_samples * sizeof(int16_t));
if (!inference.buffer) return false;
inference.buf_count = 0;
inference.n_samples = n_samples;
inference.buf_ready = 0;
I2S.setPinsPdmRx(I2S_CLK, I2S_DIN);
if (!I2S.begin(I2S_MODE_PDM_RX, SAMPLERATE, I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO)) {
ei_printf("Can't find microphone!\r\n");
return false;
}
record_status = true;
xTaskCreate(capture_samples, "CaptureSamples", 1024 * 32, (void *)sample_buffer_size, 10, NULL);
return true;
}
Alocamos memória para o buffer de inferência e configuramos o I2S para microfones PDM. Se tudo correr bem, uma tarefa FreeRTOS começa a capturar amostras em segundo plano.
Esperar por Novo Áudio
Esta função espera até que o buffer esteja pronto com novos dados.
static bool microphone_inference_record(void) {
while (inference.buf_ready == 0) delay(10);
inference.buf_ready = 0;
return true;
}
O código pausa até que haja áudio suficiente para classificação.
Converter Dados de Áudio
O classificador Edge Impulse espera amostras de áudio em ponto flutuante. Esta função converte inteiros de 16 bits em floats.
static int microphone_audio_signal_get_data(size_t offset, size_t length, float *out_ptr) {
numpy::int16_to_float(&inference.buffer[offset], out_ptr, length);
return 0;
}
Parar o Microfone
Quando não precisamos mais de entrada de áudio, paramos o I2S e libertamos a memória.
static void microphone_inference_end(void) {
I2S.end();
free(inference.buffer);
}
Funções de Controlo dos LEDs
De seguida preparamos funções auxiliares para controlar os LEDs.
void initLEDs() {
pinMode(redPin, OUTPUT);
pinMode(yellowPin, OUTPUT);
pinMode(greenPin, OUTPUT);
}
void switchOffLEDs() {
digitalWrite(redPin, LOW);
digitalWrite(yellowPin, LOW);
digitalWrite(greenPin, LOW);
}
void flashLED(const char* label) {
switchOffLEDs();
if(!strcmp("red", label)) {
digitalWrite(redPin, HIGH);
}
if(!strcmp("yellow", label)) {
digitalWrite(yellowPin, HIGH);
}
if(!strcmp("green", label)) {
digitalWrite(greenPin, HIGH);
}
delay(500);
switchOffLEDs();
}
initLEDs() configura os pinos como saídas.switchOffLEDs() garante que nenhum LED fica ligado.flashLED() liga o LED que corresponde à etiqueta reconhecida e depois desliga-o após meio segundo.
Setup
Na função setup, inicializamos o monitor serial, os LEDs e o microfone.
void setup() {
Serial.begin(115200);
initLEDs();
if (!microphone_inference_start(EI_CLASSIFIER_RAW_SAMPLE_COUNT)) {
ei_printf("ERR: Could not allocate audio buffer\r\n");
return;
}
ei_printf("Listening...\n");
}
Se o microfone iniciar com sucesso, estamos prontos para capturar a voz.
Loop
O loop corre continuamente para processar áudio e classificar comandos falados.
void loop() {
if (!microphone_inference_record()) {
ei_printf("ERR: Failed to record audio...\n");
return;
}
signal_t signal;
signal.total_length = EI_CLASSIFIER_RAW_SAMPLE_COUNT;
signal.get_data = µphone_audio_signal_get_data;
ei_impulse_result_t result = { 0 };
EI_IMPULSE_ERROR r = run_classifier(&signal, &result, debug_nn);
if (r != EI_IMPULSE_OK) {
ei_printf("ERR: Failed to run classifier (%d)\n", r);
return;
}
float max_val = -1.0;
int max_idx = -1;
auto cl = result.classification;
for (size_t ix = 0; ix < EI_CLASSIFIER_LABEL_COUNT; ix++) {
if (cl[ix].value > max_val && strcmp("noise", cl[ix].label)) {
max_val = cl[ix].value;
max_idx = ix;
}
}
if (max_idx >= 0 && max_val > 0.3) {
ei_printf("Predicted label: %s (%.3f)\n", cl[max_idx].label, max_val);
flashLED(cl[max_idx].label);
}
}
Esperamos por um novo buffer de áudio, preparamos um objeto de sinal e executamos o classificador. O classificador devolve etiquetas com probabilidades. Ignoramos a etiqueta "noise" e escolhemos a classe com maior confiança. Se a probabilidade for maior que 0.3, piscamos o LED que corresponde à previsão.
Este código funciona bem, mas reage um pouco devagar aos comandos de voz, pois temos de esperar até encher uma janela completa de inferência. A abordagem seguinte, em streaming, é mais rápida, mas notarás que a precisão da classificação é um pouco menor.
Código em Streaming para Controlo por Voz
O classificador contínuo no código seguinte está desenhado para lidar com janelas deslizantes de áudio. Processa fatias sobrepostas de entrada, por isso não precisamos de parar e reiniciar a inferência a cada vez. Isto faz o sistema responder mais rápido às palavras-chave (mas a precisão sofre um pouco). Vê a demo seguinte:
Dá uma vista rápida ao código completo e depois exploramos as diferenças entre este e o código anterior.
#define EIDSP_QUANTIZE_FILTERBANK 0
#include "Voice_control_inferencing.h"
#include "ESP_I2S.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
I2SClass I2S;
static const uint32_t slice_size = EI_CLASSIFIER_SLICE_SIZE;
static signed short sampleBuffer[slice_size];
static bool debug_nn = false;
static int slice_ctn = 0;
static bool record_status = true;
static const int8_t I2S_CLK = 42;
static const int8_t I2S_DIN = 41;
static const uint32_t SAMPLERATE = 16000;
static const int8_t redPin = 1;
static const int8_t yellowPin = 2;
static const int8_t greenPin = 3;
static void capture_samples(void* arg) {
const float gain = 16.0;
while (record_status) {
for (uint32_t i = 0; i < slice_size; i++) {
int16_t sample = I2S.read();
sampleBuffer[i] = (int16_t)constrain(sample * gain, INT16_MIN, INT16_MAX);
}
vTaskDelay(1); // give some breathing room for the scheduler
}
vTaskDelete(NULL);
}
static bool microphone_inference_start(uint32_t n_samples) {
I2S.setPinsPdmRx(I2S_CLK, I2S_DIN);
if (!I2S.begin(I2S_MODE_PDM_RX, SAMPLERATE, I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO)) {
ei_printf("Can't find microphone!\r\n");
return false;
}
record_status = true;
xTaskCreate(capture_samples, "CaptureSamples", 1024 * 16, NULL, 10, NULL);
return true;
}
static bool microphone_inference_record(void) {
delay(1);
return true;
}
static int microphone_audio_signal_get_data(size_t offset, size_t length, float* out_ptr) {
numpy::int16_to_float(&sampleBuffer[offset], out_ptr, length);
return 0;
}
static void microphone_inference_end(void) {
I2S.end();
}
void initLEDs() {
pinMode(redPin, OUTPUT);
pinMode(yellowPin, OUTPUT);
pinMode(greenPin, OUTPUT);
}
void switchOffLEDs() {
digitalWrite(redPin, LOW);
digitalWrite(yellowPin, LOW);
digitalWrite(greenPin, LOW);
}
void flashLED(const char* label) {
switchOffLEDs();
if (!strcmp("red", label)) {
digitalWrite(redPin, HIGH);
}
if (!strcmp("yellow", label)) {
digitalWrite(yellowPin, HIGH);
}
if (!strcmp("green", label)) {
digitalWrite(greenPin, HIGH);
}
delay(500);
switchOffLEDs();
}
void setup() {
Serial.begin(115200);
initLEDs();
run_classifier_init();
ei_sleep(1000);
if (!microphone_inference_start(slice_size)) {
ei_printf("ERR: Could not start microphone\r\n");
return;
}
ei_printf("Listening continously...\n");
}
void loop() {
if (!microphone_inference_record()) {
ei_printf("ERR: Failed to record audio...\n");
return;
}
signal_t signal;
signal.total_length = slice_size;
signal.get_data = µphone_audio_signal_get_data;
ei_impulse_result_t result = { 0 };
EI_IMPULSE_ERROR r = run_classifier_continuous(&signal, &result, debug_nn);
if (r != EI_IMPULSE_OK) {
ei_printf("ERR: Failed to run classifier (%d)\n", r);
return;
}
float max_val = -1.0;
int max_idx = -1;
auto cl = result.classification;
for (size_t ix = 0; ix < EI_CLASSIFIER_LABEL_COUNT; ix++) {
if (cl[ix].value > max_val && strcmp("noise", cl[ix].label)) {
max_val = cl[ix].value;
max_idx = ix;
}
}
slice_ctn = max(--slice_ctn, 0);
if (max_idx >= 0 && max_val > 0.5 && slice_ctn <= 0) {
ei_printf("Predicted label: %s (%.3f)\n", cl[max_idx].label, max_val);
flashLED(cl[max_idx].label);
slice_ctn = EI_CLASSIFIER_SLICES_PER_MODEL_WINDOW;
}
}
Diretiva de Pré-processador
Logo no topo, este código introduz uma nova definição:
#define EIDSP_QUANTIZE_FILTERBANK 0
Isto desativa a quantização do filtro dentro do pipeline DSP do Edge Impulse. O modelo usa precisão total em vez de valores comprimidos. A primeira versão não incluía esta linha, mantendo a quantização padrão.
Importações
As importações são as mesmas de antes: código de inferência Edge Impulse, suporte I2S e FreeRTOS para multitasking. Como antes, o nome da biblioteca Voice_control_inferencing.h dependerá do nome do teu projeto Edge Impulse e da biblioteca implementada. Podes ter de ajustar isto.
#include "Voice_control_inferencing.h" #include "ESP_I2S.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h"
Buffers e Variáveis
Em vez de criar um buffer grande de inferência, esta versão trabalha com fatias de áudio.
static const uint32_t slice_size = EI_CLASSIFIER_SLICE_SIZE; static signed short sampleBuffer[slice_size]; static int slice_ctn = 0;
No código anterior, o buffer continha uma janela completa de amostra (EI_CLASSIFIER_RAW_SAMPLE_COUNT). Aqui, só uma fatia é processada de cada vez. A variável slice_ctn ajuda a controlar as previsões para que não disparem com muita frequência.
Tarefa de Captura de Áudio
O loop de captura é familiar, mas tem uma grande mudança:
static void capture_samples(void* arg) {
const float gain = 16.0;
while (record_status) {
for (uint32_t i = 0; i < slice_size; i++) {
int16_t sample = I2S.read();
sampleBuffer[i] = (int16_t)constrain(sample * gain, INT16_MIN, INT16_MAX);
}
vTaskDelay(1); // give some breathing room for the scheduler
}
vTaskDelete(NULL);
}
Aqui enchemos continuamente apenas uma fatia em vez de uma janela completa. A chamada vTaskDelay(1) permite que o FreeRTOS agende outras tarefas de forma mais suave. A primeira versão alimentava amostras num buffer de inferência com callbacks, enquanto esta só atualiza o buffer da fatia.
Configuração do Microfone
Ambas as versões configuram o microfone com I2S, mas esta é mais simples.
static bool microphone_inference_start(uint32_t n_samples) {
I2S.setPinsPdmRx(I2S_CLK, I2S_DIN);
if (!I2S.begin(I2S_MODE_PDM_RX, SAMPLERATE, I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO)) {
ei_printf("Can't find microphone!\r\n");
return false;
}
record_status = true;
xTaskCreate(capture_samples, "CaptureSamples", 1024 * 16, NULL, 10, NULL);
return true;
}
Repara que não há alocação dinâmica de memória (malloc) nem configuração do buffer de inferência. Isto reduz a complexidade e evita fragmentação de memória.
Gravação de Áudio
A função de gravação é agora um placeholder:
static bool microphone_inference_record(void) {
delay(1);
return true;
}
Na primeira versão, esta função esperava até o buffer estar pronto. Aqui, não precisamos disso. A classificação funciona fatia a fatia.
Entrada do Classificador
A função para converter amostras brutas em valores de ponto flutuante é a mesma em ambas as versões:
static int microphone_audio_signal_get_data(size_t offset, size_t length, float* out_ptr) {
numpy::int16_to_float(&sampleBuffer[offset], out_ptr, length);
return 0;
}
LEDs
As funções dos LEDs são idênticas: acendem o LED da etiqueta reconhecida e apagam-no após meio segundo.
void flashLED(const char* label) { ... }
Função Setup
A função setup tem alguns passos novos:
run_classifier_init(); ei_sleep(1000);
O classificador é inicializado uma vez antes de usar, o que é necessário para classificação contínua. A versão anterior não precisava disso porque só fazia inferências pontuais.
O microfone também começa com slice_size em vez do número total de amostras brutas, permitindo streaming contínuo.
Função Loop
Aqui está a maior diferença. Em vez de usar run_classifier(), esta versão chama:
EI_IMPULSE_ERROR r = run_classifier_continuous(&signal, &result, debug_nn);
O classificador contínuo lida com janelas deslizantes de áudio. Processa fatias sobrepostas de entrada, por isso não precisas de parar e reiniciar a inferência a cada vez. Isto faz o sistema responder mais rápido às palavras-chave.
Outra mudança chave é como as previsões são controladas:
slice_ctn = max(--slice_ctn, 0);
if (max_idx >= 0 && max_val > 0.5 && slice_ctn <= 0) {
ei_printf("Predicted label: %s (%.3f)\n", cl[max_idx].label, max_val);
blinkLED(cl[max_idx].label);
slice_ctn = EI_CLASSIFIER_SLICES_PER_MODEL_WINDOW;
}
Depois de detetar uma etiqueta, o programa espera por uma janela completa do modelo antes de permitir outro disparo. Isto evita que o LED pisque várias vezes para a mesma palavra. O código anterior não tinha este mecanismo, podendo disparar imediatamente.
Resumo das Diferenças
A primeira versão usa uma abordagem bufferizada, pontual:
- Recolhe uma janela completa de inferência.
- Executa a classificação uma vez por buffer.
- Exige esperar até o buffer estar pronto.
- Usa
run_classifier().
Esta segunda versão usa uma abordagem em streaming, contínua:
- Trabalha com pequenas fatias de áudio.
- Executa a classificação continuamente com sobreposição.
- Não espera pela prontidão do buffer.
- Usa
run_classifier_continuous(). - Adiciona um cooldown (
slice_ctn) para evitar disparos repetidos.
Conclusões e Comentários
Neste tutorial aprendeste a criar uma aplicação de controlo por voz com a placa XIAO-ESP32-S3-Sense e a plataforma Edge Impulse. O Edge Impulse oferece muito mais do que foi abordado neste tutorial e recomendo que leias o Edge Impulse Documentation.
O exemplo simples deste tutorial permitiu-te controlar três LEDs com a tua voz. Nota que podes criar aplicações de controlo mais poderosas usando uma sequência de palavras e uma máquina de estados. Por exemplo, uma palavra de ativação como “Jarvis”, seguida de uma localização “Desk”, “Corner”, “Shelf”, seguida de um dispositivo “Light” ou “Fan”, seguida de uma ação “On” ou “Off”:
[Jarvis] -> [Desk, Corner, Shelf] -> [Light, Fan] -> [On, Off]
Com oito palavras diferentes já podes controlar 1x3x2x2 = 12 ações diferentes, como “Jarvis Desk Light On”
Além da automação doméstica, há muitas outras aplicações interessantes de classificação de som. Pensa em classificação de sons ambientais, deteção de anomalias em vibração/som, classificação de canto de pássaros, e assim por diante.
Finalmente, se quiseres detetar rostos ou pessoas, dá uma vista de olhos aos nossos Face Detection with XIAO ESP32-S3-Sense and SenseCraft AI e Edge AI Room Occupancy Sensor with ESP32 and Person Detection tutoriais.
Se tiveres alguma dúvida, sente-te à vontade para deixar nos comentários.
Boas criações 😉

