En este tutorial aprenderás a crear una aplicación de control por voz con la placa XIAO-ESP32-S3-Sense y la plataforma Edge Impulse. Primero recopilaremos datos de audio con el micrófono de la XIAO-ESP32-S3-Sense. Luego subiremos estos datos a la plataforma Edge Impulse y entrenaremos un modelo de detección de palabras clave. Finalmente, desplegaremos este modelo TinyML en la XIAO-ESP32-S3-Sense, lo que nos permitirá controlar LEDs con nuestra voz.
El modelo TinyML se entrenará para reconocer las palabras «red», «yellow» y «green». Conectaremos tres LEDs (rojo, amarillo, verde) a la placa XIAO-ESP32-S3-Sense, que podrás encender mediante comandos de voz. Mira el sistema en acción a continuación:
Si no has usado antes la XIAO-ESP32-S3 Sense, echa un vistazo al Getting started with XIAO-ESP32-S3-Sensetutorial primero, de lo contrario, comencemos…
Piezas necesarias
Necesitarás una placa XIAO ESP32 S3 Sense para probar los ejemplos de código. Ten en cuenta que la placa puede calentarse y puede que quieras colocar un pequeño Heatsink en la parte trasera (ver la pieza listada más abajo).
Además, necesitarás una tarjeta SD para almacenar las muestras de audio recogidas. He listado una tarjeta de 32 GB, pero una más pequeña (8GB) también servirá. Y, finalmente, si tu ordenador no tiene lector de tarjetas SD incorporado, también necesitarás uno externo.

Seeed Studio XIAO ESP32 S3 Sense

Cable USB C

Pequeño disipador de calor 9×9 mm

Tarjeta SD 32GB

Lector de tarjetas 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.
La placa XIAO-ESP32-S3-Sense
La placa XIAO-ESP32-S3-Sensese basa en el ESP32-S3, un chip de Espressif diseñado para tareas de IA y computación en el borde. La placa tiene cuatro partes: la placa principal, el Sense Hat, la cámara y una antena Wi-Fi externa:

El Sense Hat se conecta a la placa principal y cuenta con una ranura para tarjeta SD, un conector para la cámara y un micrófono. La imagen a continuación muestra la placa XIAO-ESP32-S3-Sense completamente ensamblada con una tarjeta SD insertada:

Sin embargo, para este proyecto no necesitaremos la antena WiFi ni la cámara. Por lo tanto, la configuración mostrada a continuación es suficiente y ni siquiera necesitarás la tarjeta SD una vez que hayas recogido las muestras de audio.

Ten en cuenta que la alimentación de la placa puede suministrarse tanto por el puerto USB tipo C como por la interfaz de carga de batería que se puede conectar a una batería LiPo de 3.7V.
Micrófono de XIAO-ESP32-S3-Sense
La XIAO-ESP32-S3-Sense viene con un micrófono digital integrado MEMS Microphone del tipo MSM261D3526H1CPM que está ubicado en el Sense Hat. Mira la imagen a continuación:

Puedes comunicarte con el micrófono a través de dos líneas de señal (PDM_CLK, PDM_DATA) mediante I2S protocol que están conectadas a IO42 y IO41 como se muestra en el esquema a continuación:

Para información más detallada, consulta nuestro Record Audio with XIAO-ESP32-S3-Sense tutorial.
Pinout de la placa XIAO-ESP32-S3-Sense
Finalmente, echemos un vistazo al pinout de la placa XIAO-ESP32-S3-Sense:

El pin 5V es el 5V del puerto USB. El pin 3V3 proporciona la salida del regulador integrado y puede entregar hasta 700mA. Y el pin GND es tierra.
En cuanto a los GPIOs: la placa ofrece 11 GPIOs digitales/analógicos pero GPIO0, GPIO3, GPIO43 y GPIO44son pines de configuración que deben estar en un estado específico durante el arranque, así que ten cuidado con ellos. Una vez que el microcontrolador está en funcionamiento, estos pines actúan como pines IO normales.
Si necesitas más ayuda, consulta el Getting started with XIAO-ESP32-S3-Sense tutorial.
Formatear la tarjeta SD para la recopilación de audio
Antes de comenzar a recopilar muestras de audio para entrenar nuestro modelo TinyML, debemos asegurarnos de que la tarjeta SD esté formateada correctamente.
Inserta la tarjeta SD en el lector de tarjetas SD, que puede estar integrado en tu ordenador o usar un lector externo (listado en Piezas necesarias).
Luego abre el Explorador (en Windows), busca la nueva unidad USB y haz clic derecho para abrir el menú. Selecciona «Mostrar más opciones» y luego «Formatear…» para abrir el diálogo de formateo:

Luego verifica que el sistema de archivos esté configurado en «FAT32» y presiona «Iniciar». Asegúrate de seleccionar la unidad USB correcta, ¡ya que formatear borrará todos los datos existentes en esa unidad!
Normalmente le doy un nombre nuevo a la unidad, por ejemplo «SAMPLES», para asegurarme de que tengo la unidad correcta y no formatear accidentalmente otra diferente.
Recopilación de muestras de audio con XIAO-ESP32-S3-Sense
A continuación escribiremos el código para recopilar las muestras de audio necesarias para entrenar nuestro modelo TinyML para control por voz. Asegúrate de haber formateado e insertado correctamente la tarjeta SD en la XIAO-ESP32-S3-Sense, como se muestra a continuación:

Queremos usar nuestra voz para encender un LED rojo, amarillo o verde. Por lo tanto, las palabras de control serán «red», «yellow» y «green», aunque podrías elegir otras palabras en cualquier idioma que prefieras.

Podrías recopilar esas muestras de audio usando tu teléfono móvil o el micrófono de tu ordenador, pero las características (sonido) de esos micrófonos serán diferentes al del XIAO-ESP32-S3-Sense. Por eso es mejor usar el micrófono de la XIAO-ESP32-S3-Sense, aunque eso requiere algo de código para grabar las muestras y almacenarlas en la tarjeta SD.
El siguiente código recopila muestras de audio. Debes ingresar la etiqueta («red», «yellow» o «green») en el Monitor Serial y presionar Enter.

Esto iniciará una grabación de 1 segundo, durante la cual debes pronunciar la palabra de control (por ejemplo, «red»). La grabación se guarda luego en la tarjeta SD como una muestra de audio. Echa un vistazo rápido al código completo primero y luego discutiremos los detalles:
#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);
}
El primer paso de este programa es incluir las librerías necesarias. Estas librerías permiten que el ESP32 use la interfaz I2S para la entrada del micrófono y para almacenar archivos en la tarjeta SD. Sin ellas, el ESP32 no podría grabar ni guardar audio.
#include "ESP_I2S.h" #include "FS.h" #include "SD.h"
Constantes
El código define una constante para el tamaño del encabezado del archivo WAV. Cada archivo WAV comienza con un encabezado de 44 bytes que contiene información como la tasa de muestreo, profundidad de bits y número de canales. Definiendo header_size, el código sabe dónde comienzan los datos reales de audio.
const int header_size = 44;
Objetos
Luego, el programa crea un objeto I2SClass. Este objeto maneja toda la comunicación con el micrófono usando el protocolo I2S. Al crear esta instancia, el ESP32 puede inicializar el micrófono, configurar sus parámetros y grabar audio.
I2SClass i2s;
scaleVolume
La función scaleVolume() ajusta el volumen del audio grabado. Toma las muestras de audio en bruto, las multiplica por un factor de ganancia y luego limita los valores para que encajen dentro del rango de audio de 16 bits. Esto asegura que el sonido no se distorsione por desbordamiento.
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);
}
}
También podrías normalizar el volumen (el factor de escala típico que observé fue 20x), pero esto no funciona con streaming y amplifica el ruido. Sin embargo, es útil para tener una idea de un factor de ganancia adecuado, por ejemplo 8x o 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
La función setup() prepara todo antes de que corra el bucle principal. Primero, inicializa el monitor serial para depuración. Luego configura los pines del micrófono con i2s.setPinsPdmRx(42, 41) y arranca la interfaz I2S a una tasa de muestreo de 16 kHz en modo mono. Si no detecta micrófono, imprime un mensaje de error. Después, inicializa la tarjeta SD en el pin 21. Si la tarjeta SD no se monta, imprime otro mensaje de error. Finalmente, el ESP32 solicita al usuario que ingrese una etiqueta a través del 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 del loop(), el programa espera la entrada del usuario desde el monitor serial. El usuario puede escribir una etiqueta, como «red» o «noise». Cuando se ingresa una etiqueta, el código crea una carpeta en la tarjeta SD con ese nombre. Esta organización facilita separar las grabaciones y simplifica la subida a la 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);
}
}
Después de establecer la etiqueta, el programa genera un nombre de archivo para la nueva grabación. Usa el formato label/label_number.wav. Por ejemplo, si la etiqueta es “red”, el primer archivo será red/red_1.wav. Cada nueva grabación incrementa el contador, así que los archivos se guardan en orden sin sobrescribir.
sprintf(filename, "/%s/%s_%d.wav", label.c_str(), label.c_str(), cnt++);
Serial.printf("Recording to: %s\n", filename);
Para grabar audio, el código llama a i2s.recordWAV(1, &wav_size). Esta función captura un segundo de audio y lo almacena en memoria como un archivo WAV. Si la grabación contiene datos de audio válidos, el programa aplica la función scaleVolume() para aumentar la ganancia de las muestras de audio.
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);
}
Una vez procesado el audio, el programa abre un archivo en la tarjeta SD y escribe los datos WAV en él. Si el archivo no puede abrirse o escribirse, imprime un mensaje de error. Tras guardar la grabación, cierra el archivo y muestra un mensaje de confirmación.
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, el bucle espera 100 milisegundos antes de volver a comprobar si hay nueva entrada del usuario. Esta pequeña pausa evita que el ESP32 sobrecargue el procesamiento de entrada serial.
delay(100);
Este proyecto transforma tu ESP32 en un práctico grabador de audio que puede capturar clips de un segundo desde un micrófono I2S y guardarlos como archivos WAV en una tarjeta SD. Con carpetas etiquetadas, es especialmente útil para crear conjuntos de datos para proyectos de aprendizaje automático, como entrenar un modelo de reconocimiento de voz.
Ejecutar el código para recopilar muestras
Flashea tu XIAO-ESP32-S3-Sense con el código y abre el monitor serial. Ingresa la etiqueta para la clase de la que quieres recopilar muestras en el cuadro de mensaje, por ejemplo «yellow» y luego presiona Enter.

El monitor serial mostrará el nombre del archivo para la muestra de audio, por ejemplo «/yellow/yellow_1.wav» y ahora tienes un segundo para decir la palabra «yellow». Una vez terminado, el texto «done.» aparecerá en el monitor serial. A continuación, un ejemplo de una muestra de audio grabada para la palabra «yellow»:
Para grabaciones posteriores de la misma clase, por ejemplo «yellow», solo presiona Enter. No ingreses la misma etiqueta de clase otra vez. Si estás listo para grabar muestras para la siguiente clase, por ejemplo «red», ingresa «red», presiona Enter y continúa grabando de la misma manera.
El código creará una nueva carpeta para cada clase («red», «green», «yellow», «noise») y almacenará las muestras de audio dentro de esa carpeta:

Grabé 50 muestras por color y 100 muestras para ruido. Para la clase «noise»: graba silencio, ruido ambiental, música, voz, todo excepto las palabras «red», «green» o «yellow». Cuantas más muestras tengas por clase, mejor, pero con 50 muestras ya se obtiene una precisión de detección decente – no excelente, pero decente ; )
Entrenamiento del modelo de control por voz usando Edge Impulse
En esta sección repasaremos los pasos necesarios para subir nuestros datos de entrenamiento, crear la canalización de preprocesamiento y modelo (impulse) y entrenar nuestro modelo TinyML.
Crea un nuevo proyecto en Edge Impulse, por ejemplo, con el nombre «Voice Control» y luego estaremos listos para subir nuestros datos de entrenamiento.
Subir datos de entrenamiento
Primero, retira la tarjeta SD de la XIAO-ESP32-S3-Sense y conéctala a tu ordenador para poder subir los datos. Aparecerá como una unidad USB, si usas un lector de tarjetas SD externo.
Luego haz clic en «Data acquisition» -> «+ Add data» -> «Upload data» para abrir el diálogo de subida de datos:

En el diálogo marca «Select a folder» y luego ingresa la etiqueta para la clase que quieres subir, por ejemplo «red» en la parte inferior:

Ahora haz clic en «Choose files» y selecciona la carpeta «red» con tus muestras de audio. Luego haz clic en «Upload data» y deberías ver cómo se suben los datos:

Repite este proceso para los otros colores y la clase de ruido. Asegúrate de ingresar la etiqueta correcta bajo «Enter label» antes de subir. Sin embargo, luego puedes eliminar muestras que hayas subido accidentalmente con la etiqueta incorrecta.
Cuando termines, cierra el diálogo presionando la x en la esquina superior derecha:

Explorar datos
Luego deberías ver el conjunto de datos en el explorador de datos. En la esquina superior izquierda verás un gráfico circular que representa la distribución de tus clases y la división Train/Test. Abajo está el conjunto de datos, donde puedes hacer clic en muestras individuales para escucharlas:

Si encuentras algo mal en tus datos, también hay funciones para eliminar muestras. Pero si estás satisfecho, el siguiente paso es crear un Impulse (preprocesamiento de datos + modelo).
Crear Impulse
Haz clic en «Create impulse» bajo «Impulse design»:

Luego añade tres bloques: «Time series data», «Audio (MFE)» y «Transfer Learning (Keyword Spotting)»:

Mantén todas las configuraciones por defecto para los bloques tal como están. En las siguientes secciones configuraremos y entrenaremos cada bloque.
MFE
Haz clic en «MFE» bajo «Impulse design»:

Esto abrirá el diálogo de configuración para el bloque MFE:

MFE significa Mel Frequency Energy y es un método de procesamiento digital de señales para extraer características de una señal de audio. Mantenemos las configuraciones del MFE tal como están. Solo presiona «Save parameters» para guardarlas.
En el explorador de características podrás ver qué tan bien funcionan estas características extraídas:

Cada punto en el gráfico representa una de nuestras muestras de audio (una palabra hablada o ruido). Idealmente, quieres que los puntos de la misma clase estén agrupados y que los grupos estén claramente separados.
El gráfico muestra algo de agrupamiento pero no es excelente. Puedes ver que los grupos para las palabras de color («red», «green», «yellow») están dispersos. Probablemente porque varié mi distancia al micrófono al hablar.
La clase «noise», en cambio, tiene forma circular, ya que la mayoría de las muestras de ruido son relativamente similares en volumen pero diferentes en contenido.
Basado en este agrupamiento, no esperaría una precisión fantástica. Puedes jugar con las configuraciones del bloque MFE y recopilar más muestras, lo que debería hacer el modelo más robusto.
Transfer Learning
Para entrenar el modelo haz clic en «Transfer learning» bajo «Impulse design»:

Esto abrirá el diálogo de configuración de la red neuronal mostrado abajo:

Puedes elegir entre dos modelos: MobileNetV1 0.1 o MobileNetV2 0.35. El segundo probablemente es más preciso pero también mucho más grande y no pude hacerlo funcionar en la XIAO-ESP32-S3-Sense. Por eso elegí el modelo MobileNetV1 0.1.

Aumenté el número de ciclos de entrenamiento a 60, establecí la tasa de aprendizaje en 0.01 y mantuve la CPU como procesador de entrenamiento. Sin embargo, ninguno de estos parámetros es muy sensible o importante para la precisión final del modelo y las configuraciones por defecto deberían funcionar bien también.
Luego haz clic en «Save & Train», lo que entrenará el modelo. Al final del proceso verás una matriz de confusión y otras métricas de rendimiento de la red impresas:

Como puedes ver, en mi caso la precisión general fue del 95% y el modelo tendía a confundir palabras de color con ruido, lo cual es esperable. Probablemente la precisión podría mejorarse con más muestras de entrenamiento, pero es un modelo muy pequeño y su capacidad de reconocimiento es limitada.
Desplegar modelo
Finalmente, necesitamos desplegar el modelo para ejecutarlo en la XIAO-ESP32-S3-Sense. Haz clic en «Deployment» bajo «Impulse design»:

En la esquina superior derecha de la pantalla puedes seleccionar el procesador objetivo para el despliegue. A partir de agosto de 2025, Edge Impulse no soporta directamente la XIAO-ESP32-S3-Sense. En su lugar, seleccionamos el Espressif ESP-EYE:

Si haces clic en él, se abrirá el diálogo de configuración para el dispositivo objetivo:

Luego corregiremos el código desplegado para que funcione con la XIAO-ESP32-S3-Sense. Para el tipo de despliegue seleccionamos «Arduino library» y «TensorFlow Lite»:

Luego haz clic en el botón de compilación para construir y desplegar el modelo:

Se descargará el modelo como un archivo ZIP, en mi caso: «ei-voice-control-arduino-1.0.8.zip«. El nombre del archivo ZIP dependerá del nombre que hayas elegido para el proyecto Edge Impulse («Voice Control»).

Para usar la librería, crea un Sketch nuevo y vacío y luego instala la librería descargada (ei-voice-...zip) como de costumbre en el IDE de Arduino vía Sketch -> Include Library -> Add .ZIP library.
Conectar LEDs a XIAO-ESP32-S3-Sense
Antes de escribir el código para controlar los LEDs, te mostraré rápidamente cómo conectar los tres LEDs a la XIAO-ESP32-S3-Sense.

El cátodo de los tres LEDs está conectado a tierra (GND). Los ánodos de los LEDs están conectados a los pines GPIO 1, 2 y 3 a través de una resistencia de 220Ω como se muestra arriba. Puedes elegir otros pines GPIO, pero si lo haces, asegúrate de ajustar el código siguiente.
Código para control por voz de LEDs
La librería ei-voice-control-arduino-1.0.8.zip que desplegamos incluye código de ejemplo, pero no podemos usarlo porque está diseñado para el ESP-EYE, que tiene una interfaz de micrófono diferente a la XIAO-ESP32-S3-Sense.
Por eso tomé el código de ejemplo y lo modifiqué para que funcione con la XIAO-ESP32-S3-Sense. Además, añadí el código para controlar los LEDs.
El siguiente código escucha el micrófono, envía la señal de audio grabada al modelo clasificador, obtiene el resultado de la clasificación y parpadea brevemente el LED rojo, amarillo o verde si se reconoce una palabra de color con suficiente confianza.
Echa un vistazo rápido al código completo primero y luego discutiremos sus detalles. Asegúrate de reemplazar la importación #include "Voice_control_inferencing.h", con el nombre de tu librería desplegada.!
#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);
}
}
Importaciones
Las primeras líneas incluyen las librerías que necesitamos. Cada una juega un papel importante en el reconocimiento de voz y el control del hardware.
#include "Voice_control_inferencing.h" #include "ESP_I2S.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h"
El archivo Voice_control_inferencing.h es generado por Edge Impulse y contiene la red neuronal entrenada. Como se mencionó antes, este nombre dependerá del nombre de tu proyecto Edge Impulse y la librería desplegada. Puede que tengas que ajustarlo.
La librería ESP_I2S.h proporciona acceso a la interfaz de micrófono I2S y los encabezados de FreeRTOS nos permiten ejecutar la captura de audio como una tarea en segundo plano.
Constantes y buffers
Luego definimos estructuras y constantes para gestionar la grabación y clasificación de audio.
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;
La estructura inference_t contiene el buffer de audio y lleva la cuenta de cuántas muestras tenemos. Reservamos sampleBuffer como espacio temporal para la entrada del micrófono. Mientras tanto, la bandera record_status controla si seguimos grabando.
Pines del micrófono y tasa de muestreo
Definimos qué pines del ESP32 se conectan al micrófono y a qué velocidad muestreamos.
static const int8_t I2S_CLK = 42; static const int8_t I2S_DIN = 41; static const uint32_t SAMPLERATE = 16000;
El micrófono se conecta mediante I2S. El pin 42 proporciona la señal de reloj y el pin 41 es la entrada de datos. Muestreamos a 16 kHz, ideal para reconocimiento de voz.
Pines de los LEDs
Asignamos tres pines para la retroalimentación visual.
static const int8_t redPin = 1; static const int8_t yellowPin = 2; static const int8_t greenPin = 3;
Cada pin controla un LED. Parpadearán cuando el clasificador detecte la palabra de color correspondiente.
Callback de inferencia de audio
Esta función mueve muestras del buffer temporal al buffer principal de inferencia.
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;
}
}
}
Cuando el buffer se llena, lo marcamos como listo para que el clasificador lo use.
Captura de muestras del micrófono
En lugar de grabar audio en el bucle principal, ejecutamos esta tarea en 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);
}
Leemos continuamente muestras del micrófono usando I2S.read(). Cada muestra se amplifica con un factor de ganancia antes de almacenarse. Cuando se recopilan suficientes muestras, las pasamos para inferencia.
Inicio de la inferencia del micrófono
Esta función inicializa el micrófono y comienza la tarea 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;
}
Reservamos memoria para el buffer de inferencia y configuramos I2S para micrófonos PDM. Si todo funciona, una tarea FreeRTOS comienza a capturar muestras en segundo plano.
Esperando nuevo audio
Esta función espera hasta que el buffer esté listo con nuevos datos.
static bool microphone_inference_record(void) {
while (inference.buf_ready == 0) delay(10);
inference.buf_ready = 0;
return true;
}
El código se pausa hasta que se recopila suficiente audio para la clasificación.
Conversión de datos de audio
El clasificador de Edge Impulse espera muestras de audio en punto flotante. Esta función convierte enteros de 16 bits en 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;
}
Detener el micrófono
Cuando ya no necesitamos entrada de audio, detenemos I2S y liberamos memoria.
static void microphone_inference_end(void) {
I2S.end();
free(inference.buffer);
}
Funciones de control de LEDs
Luego preparamos funciones auxiliares para controlar los 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 los pines como salidas.switchOffLEDs() asegura que ningún LED quede encendido.flashLED() enciende el LED que coincide con la etiqueta reconocida y luego lo apaga después de medio segundo.
Setup
En la función setup, inicializamos el monitor serial, los LEDs y el micrófono.
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");
}
Si el micrófono arranca correctamente, estamos listos para capturar la entrada de voz.
Loop
El loop corre continuamente para procesar audio y clasificar comandos hablados.
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 un nuevo buffer de audio, preparamos un objeto señal y ejecutamos el clasificador. El clasificador devuelve etiquetas con probabilidades. Ignoramos la etiqueta "noise" y elegimos la clase con mayor confianza. Si la probabilidad es mayor que 0.3, parpadeamos el LED que coincide con la predicción.
Este código funciona bien pero reacciona un poco lento a los comandos de voz, ya que hay que esperar a que se llene una ventana completa de inferencia. El siguiente enfoque de streaming es más rápido, pero notarás que la precisión de clasificación es un poco menor.
Código de streaming para control por voz
El clasificador continuo en el siguiente código está diseñado para manejar ventanas deslizantes de audio. Procesa fragmentos superpuestos de entrada, por lo que no necesitamos detener y reiniciar la inferencia cada vez. Esto hace que el sistema responda más rápido a las palabras clave (aunque la precisión sufre un poco). Mira la demo siguiente:
Echa un vistazo rápido al código completo y luego profundizaremos en las diferencias con el 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;
}
}
Directiva del preprocesador
Justo al principio, este código introduce una nueva definición:
#define EIDSP_QUANTIZE_FILTERBANK 0
Esto desactiva la cuantización del banco de filtros dentro de la canalización DSP de Edge Impulse. El modelo usa precisión completa en lugar de valores comprimidos. La primera versión no incluía esta línea, por lo que mantenía la cuantización por defecto.
Importaciones
Las importaciones son las mismas que antes: código de inferencia de Edge Impulse, soporte I2S y FreeRTOS para multitarea. Como antes, el nombre de la librería Voice_control_inferencing.h dependerá del nombre de tu proyecto Edge Impulse y la librería desplegada. Puede que tengas que ajustarlo.
#include "Voice_control_inferencing.h" #include "ESP_I2S.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h"
Buffers y variables
En lugar de crear un buffer grande de inferencia, esta versión trabaja con fragmentos de audio.
static const uint32_t slice_size = EI_CLASSIFIER_SLICE_SIZE; static signed short sampleBuffer[slice_size]; static int slice_ctn = 0;
En el código anterior, el buffer contenía una ventana completa de muestra (EI_CLASSIFIER_RAW_SAMPLE_COUNT). Aquí, solo se procesa un fragmento a la vez. La variable slice_ctn ayuda a limitar las predicciones para que no se activen con demasiada frecuencia.
Tarea de captura de audio
El bucle de captura es similar pero tiene un gran cambio:
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);
}
Aquí llenamos continuamente solo un fragmento en lugar de una ventana completa. La llamada vTaskDelay(1) permite que FreeRTOS programe otras tareas más suavemente. La primera versión alimentaba muestras a un buffer de inferencia con callbacks, mientras que esta solo actualiza el buffer del fragmento.
Configuración del micrófono
Ambas versiones configuran el micrófono con I2S, pero esta es más simple.
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;
}
Observa que no hay asignación dinámica de memoria (malloc) ni configuración del buffer de inferencia. Esto reduce la complejidad y evita fragmentación de memoria.
Grabación de audio
La función de grabación ahora es un marcador de posición:
static bool microphone_inference_record(void) {
delay(1);
return true;
}
En la primera versión, esta función esperaba hasta que el buffer estuviera listo. Aquí no necesitamos eso. La clasificación funciona fragmento a fragmento.
Entrada del clasificador
La función para convertir muestras en bruto a valores de punto flotante es la misma en ambas versiones:
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
Las funciones de los LEDs son idénticas: encienden el LED de la etiqueta reconocida y lo apagan después de medio segundo.
void flashLED(const char* label) { ... }
Función setup
La función setup tiene algunos pasos nuevos:
run_classifier_init(); ei_sleep(1000);
El clasificador se inicializa una vez antes de usarlo, lo que es necesario para la clasificación continua. La versión anterior no lo necesitaba porque solo hacía inferencias puntuales.
El micrófono también arranca con slice_size en lugar del conteo completo de muestras en bruto, habilitando el streaming continuo.
Función loop
Aquí está la mayor diferencia. En lugar de usar run_classifier(), esta versión llama a:
EI_IMPULSE_ERROR r = run_classifier_continuous(&signal, &result, debug_nn);
El clasificador continuo maneja ventanas deslizantes de audio. Procesa fragmentos superpuestos de entrada, por lo que no necesitas detener y reiniciar la inferencia cada vez. Esto hace que el sistema responda más rápido a las palabras clave.
Otro cambio clave es cómo se limitan las predicciones:
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;
}
Después de detectar una etiqueta, el programa espera una ventana completa del modelo antes de permitir otro disparo. Esto evita que el LED parpadee varias veces por la misma palabra. El código anterior no tenía este mecanismo, por lo que podía disparar repetidamente de inmediato.
Resumen de diferencias
La primera versión usa un enfoque bufferizado y puntual:
- Recopila una ventana completa de inferencia.
- Ejecuta la clasificación una vez por buffer.
- Requiere esperar a que el buffer esté listo.
- Usa
run_classifier().
Esta segunda versión usa un enfoque de streaming continuo:
- Trabaja con pequeños fragmentos de audio.
- Ejecuta la clasificación continuamente con solapamiento.
- No espera a que el buffer esté listo.
- Usa
run_classifier_continuous(). - Añade un tiempo de espera (
slice_ctn) para evitar disparos repetidos.
Conclusiones y comentarios
En este tutorial aprendiste a crear una aplicación de control por voz con la placa XIAO-ESP32-S3-Sense y la plataforma Edge Impulse. Edge Impulse ofrece mucho más de lo que cubrimos aquí y te recomiendo leer el Edge Impulse Documentation.
El ejemplo sencillo de este tutorial te permitió controlar tres LEDs con tu voz. Ten en cuenta que puedes crear aplicaciones de control más potentes usando una secuencia de palabras y una máquina de estados. Por ejemplo, una palabra de activación como «Jarvis», seguida de una ubicación «Desk», «Corner», «Shelf», seguida de un dispositivo «Light» o «Fan», seguida de una acción «On» o «Off»:
[Jarvis] -> [Desk, Corner, Shelf] -> [Light, Fan] -> [On, Off]
Con ocho palabras diferentes ya puedes controlar 1x3x2x2 = 12 acciones distintas como «Jarvis Desk Light On»
Además de la domótica, hay muchas otras aplicaciones interesantes de clasificación de sonido. Piensa en clasificación de sonidos ambientales, detección de anomalías en vibración/sonido, clasificación de canto de aves, etc.
Finalmente, si quieres detectar caras o personas, echa un vistazo a nuestros Face Detection with XIAO ESP32-S3-Sense and SenseCraft AI y Edge AI Room Occupancy Sensor with ESP32 and Person Detectiontutoriales.
Si tienes alguna pregunta, no dudes en dejarla en la sección de comentarios.
¡Feliz bricolaje! 😉

