Objetivo y caso de uso
Qué construirás: Un detector de palabras clave utilizando un Arduino Nano 33 IoT y un micrófono INMP441 I2S para procesar audio en tiempo real.
Para qué sirve
- Detección de comandos de voz en sistemas de automatización del hogar.
- Interacción con dispositivos IoT mediante palabras clave específicas.
- Implementación en sistemas de asistencia personal que responden a órdenes vocales.
Resultado esperado
- Latencia de detección de palabras clave inferior a 200 ms.
- Precisión de detección superior al 85% en condiciones controladas.
- Capacidad de procesar hasta 10 palabras clave simultáneamente.
Público objetivo: Desarrolladores de IoT; Nivel: Avanzado
Arquitectura/flujo: Captura de audio I2S -> Procesamiento de características -> Clasificación en tiempo real -> Respuesta a comandos.
Nivel: Avanzado
Prerrequisitos
Sistema operativo y herramientas (versiones probadas)
- Sistema operativo
- Ubuntu 22.04.4 LTS (x86_64). También válido en macOS 13 Ventura y Windows 11, ajustando rutas/comandos.
- Toolchain para la placa
- Arduino CLI v0.35.3
- Core SAMD para Arduino: arduino:samd@1.8.14
- Librerías Arduino:
- Arduino_I2S@1.0.3
- arduinoFFT@1.6.1
- Toolchain científico (para entrenamiento de un clasificador simple)
- Python 3.11.6
- Paquetes:
- numpy==1.26.4
- scikit-learn==1.4.2
- pyserial==3.5
Qué aprenderás y qué harás
- Capturar audio por I2S a 16 kHz desde el micrófono INMP441 en un Arduino Nano 33 IoT.
- Extraer características log-mel de ventana corta (MFBE) y aplicar DCT para obtener MFCC.
- Entrenar un clasificador lineal (logistic regression) en PC con Python, exportando pesos a C++.
- Embebido del clasificador en el firmware del Nano 33 IoT con inferencia en tiempo real.
- Validar detecciones y afinar umbrales.
Materiales
- 1x Arduino Nano 33 IoT (modelo exacto: ABX00032; MCU SAMD21G18A + NINA-W102; alimentación 3.3 V)
- 1x Micrófono I2S INMP441 (breakout de 3.3 V; pines: VDD, GND, SCK/BCLK, WS/LRCLK, SD, L/R)
- 1x Cable micro‑USB de datos para el Nano 33 IoT
- Cables Dupont macho‑hembra para las conexiones
- Opcional:
- Protoboard
- PC con puertos USB y Python 3.11
Nota sobre alimentación: el INMP441 opera a 3.3 V; no uses 5 V. El Nano 33 IoT trabaja íntegramente a 3.3 V, por lo que no necesitas conversores de nivel.
Preparación y conexión
Instalación del toolchain de Arduino CLI
- Instala Arduino CLI v0.35.3:
«`bash
# En Linux x86_64
curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh
sudo mv bin/arduino-cli /usr/local/bin/
# Verifica versión
arduino-cli version
# Debería mostrar: arduino-cli Version: 0.35.3
2. Instala el core SAMD y librerías necesarias:bash
arduino-cli core update-index
arduino-cli core install arduino:samd@1.8.14
# Librerías:
arduino-cli lib install «Arduino_I2S@1.0.3» «arduinoFFT@1.6.1»
3. Conecta el Nano 33 IoT por USB y lista puertos:bash
arduino-cli board list
# Ejemplo de salida en Linux:
# Port Type Board Name FQBN
# /dev/ttyACM0 Serial Port (USB) Arduino Nano 33 IoT arduino:samd:nano_33_iot
«`
Notas de driver:
– Linux/macOS: dispositivo CDC ACM /dev/ttyACM o /dev/cu.usbmodem; no necesitas drivers extra.
– Windows 10/11: Windows Update instala el driver CDC automáticamente.
Conexión eléctrica (I2S)
La interfaz I2S en el Nano 33 IoT está implementada en el MCU SAMD21. Para este caso práctico se utiliza el bus I2S en modo receptor con el micrófono INMP441 (no requiere MCLK). Configuración típica a 16 kHz, 32 bits por muestra (el INMP441 emite 24 bits válidos en 32).
Conecta según la tabla:
| Señal INMP441 | Señal I2S | Pin Arduino Nano 33 IoT | Notas |
|---|---|---|---|
| VDD | 3V3 | 3V3 (pin 3.3V) | Alimentación 3.3 V |
| GND | GND | GND | Tierra común |
| SCK (BCLK) | I2S BCLK | D2 | Reloj de bit |
| WS (LRCLK) | I2S LRCLK | D3 | Reloj de palabra/canal |
| SD | I2S SD | D4 | Datos de micrófono (entrada al Nano) |
| L/R | Selección de canal | GND (sugerido) | GND = canal izquierdo; VDD = derecho |
Observaciones:
– El INMP441 no requiere MCLK (Master Clock), lo cual simplifica el cableado.
– Mantén los cables de I2S lo más cortos posible.
– Asegura una masa común robusta entre placa y micrófono.
Verificación de pines I2S por firmware:
– En caso de duda, puedes confirmar en tiempo de compilación usando las macros del core SAMD: PIN_I2S_SCK, PIN_I2S_FS, PIN_I2S_SD. En este caso práctico usaremos D2/D3/D4 como asignación estándar para Nano 33 IoT.
Código completo (firmware Arduino) y explicación
A continuación se presenta un firmware autocontenido que:
– Inicializa I2S a 16 kHz, 32 bits.
– Captura frames de 30 ms (480 muestras) con salto de 10 ms (160 muestras).
– Extrae 20 bandas log-mel y aplica DCT para 13 MFCC por frame.
– Mantiene una ventana de 25 frames (~250 ms) para formar un “feature map” de 25×13 = 325 features.
– Aplica un clasificador logístico (pesos que exportaremos desde Python) para detectar la palabra clave.
– Emite por Serial la probabilidad y disparos de detección.
El clasificador está separado en un archivo de cabecera “weights.h” que generaremos tras el entrenamiento. Para poder compilar desde ya, incluimos unos pesos de ejemplo con bias=0 y todos los pesos a 0 (no detectará nada) y se reemplazan más adelante.
Crea la estructura de proyecto:
– Directorio del sketch: i2s_kws_nano33iot/
– i2s_kws_nano33iot.ino
– weights.h
Contenido:
// File: i2s_kws_nano33iot.ino
#include <Arduino.h>
#include <I2S.h> // Arduino_I2S
#include <arduinoFFT.h> // arduinoFFT
#include "weights.h" // Pesos del clasificador (auto-generado por Python)
// Parámetros de audio
static const uint32_t SAMPLE_RATE = 16000; // 16 kHz
static const uint16_t BITS_PER_SAMPLE = 32; // INMP441 -> 24 bits válidos en 32
static const uint16_t FRAME_LEN = 480; // 30 ms a 16 kHz
static const uint16_t FRAME_HOP = 160; // 10 ms
static const uint16_t FFT_SIZE = 512; // Siguiente potencia de 2 >= FRAME_LEN
static const uint8_t NUM_MEL = 20; // Nº de bandas mel
static const uint8_t NUM_MFCC = 13; // Nº de coeficientes MFCC
static const uint8_t NUM_FRAMES_STACK = 25; // ~250 ms de contexto
static const float PREEMPHASIS = 0.97f;
// Buffers
static int16_t ringBuffer[FRAME_LEN]; // Ventana actual (16 bits)
static float frameF32[FRAME_LEN]; // Copia en float
static float fftReal[FFT_SIZE];
static float fftImag[FFT_SIZE];
static float melEnergies[NUM_MEL];
static float mfcc[NUM_MFCC];
static float featStack[NUM_FRAMES_STACK * NUM_MFCC]; // 25x13 = 325 features
// FFT
arduinoFFT FFT = arduinoFFT(fftReal, fftImag, FFT_SIZE, SAMPLE_RATE);
// Tabla de filtros mel (precomputada en setup)
static uint16_t melLowerBin[NUM_MEL];
static uint16_t melUpperBin[NUM_MEL];
static float melWeights[NUM_MEL][FFT_SIZE/2 + 1];
// Prototipos
void computeMelFilterbank();
void frameToMFCC(const int16_t *pcm, float *out_mfcc);
void computeLogMel(const float *magSpec, float *out_mel);
void dct13(const float *in, float *out);
float logistic(const float x);
float dotProduct(const float *a, const float *b, size_t n);
void pushFrameFeatures(const float *mfcc);
void inferAndReport();
// Utilidad: lectura robusta de muestras desde I2S (descarta underflows)
bool readI2SSamples(int16_t *dst, size_t nSamples) {
size_t count = 0;
while (count < nSamples) {
int32_t s = I2S.read();
if (s == 0) {
// read() devuelve 0 si no hay dato listo; espera breve
delayMicroseconds(50);
continue;
}
// INMP441: 24-bit en 32-bit firmado; escalar a 16-bit
int16_t v = (int16_t)(s >> 14); // Ajuste empírico (de 32 a ~18 bits -> 16 bits)
dst[count++] = v;
}
return true;
}
void setup() {
Serial.begin(115200);
while (!Serial) {;}
Serial.println("Init I2S KWS (Nano 33 IoT + INMP441)");
// Inicializa I2S receptor: modo Philips, 16kHz, 32 bits
if (!I2S.begin(I2S_PHILIPS_MODE, SAMPLE_RATE, BITS_PER_SAMPLE)) {
Serial.println("Error: no se pudo iniciar I2S");
while (1) { delay(1000); }
}
// Nota: El I2S del Nano 33 IoT usa pines fijos. Este sketch asume:
// D2=BCLK, D3=LRCLK, D4=SD. Revisa tu conexionado.
// Inicializa filtros mel (triángulos sobre espectro de magnitud)
computeMelFilterbank();
// Inicializa buffer de características apiladas
memset(featStack, 0, sizeof(featStack));
Serial.println("I2S listo. Capturando...");
}
void loop() {
// Desplazamiento de FRAME_HOP:
// - Leer FRAME_HOP nuevas muestras
// - Mantener una ventana actual con tamaño FRAME_LEN para extracción de MFCC
static int16_t window[FRAME_LEN] = {0};
static size_t writePos = 0;
// Lee FRAME_HOP muestras nuevas
int16_t hopBuf[FRAME_HOP];
readI2SSamples(hopBuf, FRAME_HOP);
// Desplaza ventana: elimina FRAME_HOP iniciales, añade FRAME_HOP al final
memmove(window, window + FRAME_HOP, (FRAME_LEN - FRAME_HOP) * sizeof(int16_t));
memcpy(window + (FRAME_LEN - FRAME_HOP), hopBuf, FRAME_HOP * sizeof(int16_t));
// Extrae MFCC de la ventana actual
frameToMFCC(window, mfcc);
// Apila y ejecuta inferencia cuando tengamos NUM_FRAMES_STACK
pushFrameFeatures(mfcc);
inferAndReport();
}
void computeMelFilterbank() {
// Definición de los límites de frecuencia
float fMin = 20.0f;
float fMax = SAMPLE_RATE / 2.0f;
auto hzToMel = [](float hz) {
return 2595.0f * log10f(1.0f + hz / 700.0f);
};
auto melToHz = [](float mel) {
return 700.0f * (powf(10.0f, mel / 2595.0f) - 1.0f);
};
float melMin = hzToMel(fMin);
float melMax = hzToMel(fMax);
float melStep = (melMax - melMin) / (NUM_MEL + 1);
// Puntos mel
float melPts[NUM_MEL + 2];
for (int i = 0; i < NUM_MEL + 2; ++i) {
melPts[i] = melMin + i * melStep;
}
// Convertir a bins de FFT
for (int m = 0; m < NUM_MEL + 2; ++m) {
float hz = melToHz(melPts[m]);
int bin = (int) floorf((FFT_SIZE + 1) * hz / SAMPLE_RATE);
if (m > 0 && m < NUM_MEL + 1) {
// Guardar bordes inferiores/superiores por banda
melLowerBin[m - 1] = (uint16_t) bin;
melUpperBin[m - 1] = (uint16_t) bin; // se corrige en el bucle siguiente
}
}
// Construir filtros triangulares
// Limpia pesos
for (int i = 0; i < NUM_MEL; i++) {
for (int k = 0; k <= FFT_SIZE / 2; k++) {
melWeights[i][k] = 0.0f;
}
}
for (int m = 1; m <= NUM_MEL; ++m) {
int f_m_minus = (int) floorf((FFT_SIZE + 1) * melToHz(melPts[m - 1]) / SAMPLE_RATE);
int f_m = (int) floorf((FFT_SIZE + 1) * melToHz(melPts[m]) / SAMPLE_RATE);
int f_m_plus = (int) floorf((FFT_SIZE + 1) * melToHz(melPts[m + 1]) / SAMPLE_RATE);
for (int k = f_m_minus; k < f_m; ++k) {
if (k >= 0 && k <= FFT_SIZE / 2) {
melWeights[m - 1][k] = (float)(k - f_m_minus) / (float)(f_m - f_m_minus + 1e-9f);
}
}
for (int k = f_m; k < f_m_plus; ++k) {
if (k >= 0 && k <= FFT_SIZE / 2) {
melWeights[m - 1][k] = (float)(f_m_plus - k) / (float)(f_m_plus - f_m + 1e-9f);
}
}
}
}
void frameToMFCC(const int16_t *pcm, float *out_mfcc) {
// Pre-énfasis y ventana Hann
for (int i = 0; i < FRAME_LEN; i++) {
float x = (float)pcm[i] / 32768.0f;
if (i > 0) {
x = x - PREEMPHASIS * ((float)pcm[i - 1] / 32768.0f);
}
float w = 0.5f - 0.5f * cosf(2.0f * PI * i / (FRAME_LEN - 1));
frameF32[i] = x * w;
}
// Relleno a FFT_SIZE
for (int i = 0; i < FFT_SIZE; i++) {
if (i < FRAME_LEN) {
fftReal[i] = frameF32[i];
} else {
fftReal[i] = 0.0f;
}
fftImag[i] = 0.0f;
}
// FFT
FFT.Windowing(FFT_WIN_TYP_RECTANGLE, FFT_FORWARD); // ya aplicamos ventana Hann
FFT.Compute(FFT_FORWARD);
FFT.ComplexToMagnitude();
// Magnitud espectral hasta Nyquist
// fftReal[k] contiene magnitud; ignorar bin 0 DC en log-mel
computeLogMel(fftReal, melEnergies);
// DCT a 13 coeficientes
dct13(melEnergies, out_mfcc);
// Normalización simple (opc.): media ~0
// Aquí se puede aplicar CMVN si se desea; para simplicidad lo omitimos.
}
void computeLogMel(const float *magSpec, float *out_mel) {
for (int m = 0; m < NUM_MEL; m++) {
float e = 0.0f;
for (int k = 0; k <= FFT_SIZE/2; k++) {
e += magSpec[k] * melWeights[m][k];
}
out_mel[m] = logf(e + 1e-6f); // log-amplitud
}
}
void dct13(const float *in, float *out) {
// DCT-II ortonormal aproximada para 13 coeficientes sobre NUM_MEL entradas
for (int n = 0; n < NUM_MFCC; n++) {
float sum = 0.0f;
for (int m = 0; m < NUM_MEL; m++) {
sum += in[m] * cosf(PI * (m + 0.5f) * n / (float)NUM_MEL);
}
out[n] = sum; // sin normalización adicional por simplicidad
}
}
float logistic(const float x) {
// Evitar overflow
if (x > 20.0f) return 1.0f;
if (x < -20.0f) return 0.0f;
return 1.0f / (1.0f + expf(-x));
}
void pushFrameFeatures(const float *mfcc) {
// Desplaza hacia la izquierda un bloque de NUM_MFCC y añade al final
memmove(featStack, featStack + NUM_MFCC, sizeof(float) * NUM_MFCC * (NUM_FRAMES_STACK - 1));
memcpy(featStack + NUM_MFCC * (NUM_FRAMES_STACK - 1), mfcc, sizeof(float) * NUM_MFCC);
}
void inferAndReport() {
// Inferencia basada en los features apilados (325 features)
// Dot product + bias -> sigmoid -> prob.
float score = dotProduct(featStack, KWS_WEIGHTS, KWS_FEATURES);
score += KWS_BIAS;
float prob = logistic(score);
// Heurística de disparo: histéresis simple
static bool triggered = false;
static uint32_t lastTrigger = 0;
const float TH_ON = 0.75f;
const float TH_OFF = 0.65f;
const uint32_t REFRACTORY_MS = 1000;
uint32_t now = millis();
if (!triggered && prob > TH_ON && (now - lastTrigger) > REFRACTORY_MS) {
triggered = true;
lastTrigger = now;
Serial.print("DETECCION: prob=");
Serial.println(prob, 3);
} else if (triggered && prob < TH_OFF) {
triggered = false;
}
// Telemetría (opcional): comentar si causa latencias
Serial.print("p=");
Serial.println(prob, 3);
}
float dotProduct(const float *a, const float *b, size_t n) {
float s = 0.0f;
for (size_t i = 0; i < n; i++) s += a[i] * b[i];
return s;
}
Archivo de pesos (temporal, será reemplazado luego por entrenamiento):
// File: weights.h
#pragma once
// Nº de características: NUM_FRAMES_STACK * NUM_MFCC = 25 * 13 = 325
#define KWS_FEATURES 325
// Pesos iniciales de marcador de posición (todo a 0.0f); serán generados por Python
static const float KWS_WEIGHTS[KWS_FEATURES] = {
// Se sobreescribirá con pesos reales; mantener longitud 325
#define Z0 0.0f
Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,Z0,
Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0, Z0,Z0,Z0,Z0,Z0
#undef Z0
};
static const float KWS_BIAS = 0.0f;
Explicación breve de las partes clave:
– Captura I2S: I2S.begin(I2S_PHILIPS_MODE, 16000, 32) configura el bus en modo receptor. INMP441 entrega 24 bits útiles; se reducen a int16 con un shift.
– Ventaneo: FRAME_LEN=480 muestras (30 ms) con hop de 160 muestras (10 ms) para solapidado.
– FFT y mel: Se aplica ventana Hann, FFT 512, se genera un banco de 20 filtros mel triangulares y se calcula log-energía.
– MFCC: DCT tipo II para 13 coeficientes por frame. Se apilan 25 frames (~250 ms).
– Clasificador: producto punto con pesos exportados + función logística; se reporta probabilidad y se aplica histéresis con periodo refractario.
Compilación, flashing y ejecución
Asumiendo que tu sketch está en ~/proyectos/i2s_kws_nano33iot:
- Compilar (Nano 33 IoT):
bash
arduino-cli compile \
--fqbn arduino:samd:nano_33_iot \
~/proyectos/i2s_kws_nano33iot - Subir (ajusta el puerto al listado por board list):
bash
# En Linux suele ser /dev/ttyACM0
arduino-cli upload \
-p /dev/ttyACM0 \
--fqbn arduino:samd:nano_33_iot \
~/proyectos/i2s_kws_nano33iot - Abrir monitor serie a 115200 baudios:
bash
arduino-cli monitor -p /dev/ttyACM0 -c baudrate=115200
Salida esperada inicial (sin pesos reales):
– Mensaje de inicio “Init I2S KWS…” y líneas “p=0.500” fluctuando cerca de 0.5 (con pesos cero, la sigmoide de 0 produce 0.5).
Entrenamiento del clasificador y exportación de pesos
Para pasar de “esqueleto” a un KWS real, capturaremos ejemplos de la palabra clave (ej.: “hola”) y de fondo/noise, entrenaremos un clasificador logístico y exportaremos sus pesos a weights.h.
Preparación del entorno Python
cd ~/proyectos/i2s_kws_nano33iot
python3 -m venv .venv
source .venv/bin/activate
pip install numpy==1.26.4 scikit-learn==1.4.2 pyserial==3.5
Firmware de captura rápida
Usaremos el propio firmware para stream de MFCC ya calculados, simplificando dataset y entrenamiento (features ya procesadas). Añade en el loop un modo de “dump” controlado por comando serie o, más simple, crea un pequeño script Python que escuche “p=” y MFCC si lo deseas. Aquí proponemos una segunda sketch minimal para streaming de MFCC en lugar de probabilidad. Alternativamente, modifica el actual para imprimir MFCC cuando reciba ‘F’.
Para rapidez, usaremos un script Python que escucha “MFCC:” que enviaremos. Modifica temporalmente inferAndReport así:
- Sustituye Serial.print(«p=»…) por impresión de MFCC:
// Sustituye inferAndReport por esta versión temporal para recolectar MFCC
void inferAndReport() {
// Imprime MFCC de la última ventana apilada (25x13)
Serial.print("MFCC:");
for (int i = 0; i < NUM_FRAMES_STACK * NUM_MFCC; i++) {
Serial.print(featStack[i], 6);
if (i < NUM_FRAMES_STACK * NUM_MFCC - 1) Serial.print(',');
}
Serial.println();
}
Compila y sube de nuevo. Abre el monitor para verificar que salen líneas “MFCC:…”.
Script Python de captura etiquetada
Crea capture.py para etiquetar en vivo (presiona ‘k’ cuando digas la palabra, ‘n’ para ruido):
# File: capture.py
import sys, time, serial, threading
from datetime import datetime
PORT = sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyACM0"
BAUD = 115200
OUT = "dataset.csv"
print(f"Abrir {PORT} @ {BAUD}")
ser = serial.Serial(PORT, BAUD, timeout=1)
label = "noise"
running = True
count = {"noise":0, "keyword":0}
def key_reader():
global label, running
try:
while running:
k = sys.stdin.read(1)
if k == 'k':
label = "keyword"
print("[LABEL] keyword")
elif k == 'n':
label = "noise"
print("[LABEL] noise")
elif k == 'q':
running = False
break
except Exception as e:
print("Key thread error:", e)
threading.Thread(target=key_reader, daemon=True).start()
with open(OUT, "w") as f:
# Cabecera
cols = [f"f{i}" for i in range(325)]
f.write("label," + ",".join(cols) + "\n")
try:
while running:
line = ser.readline().decode(errors="ignore").strip()
if line.startswith("MFCC:"):
data = line.split("MFCC:")[1].strip()
parts = data.split(",")
if len(parts) != 325:
continue
f.write(label + "," + ",".join(parts) + "\n")
f.flush()
count[label] += 1
if (count["noise"] + count["keyword"]) % 20 == 0:
print(f"Samples -> noise: {count['noise']}, keyword: {count['keyword']}")
except KeyboardInterrupt:
pass
running = False
ser.close()
print("Guardado en", OUT)
Uso:
python capture.py /dev/ttyACM0
# Pulsa 'k' mientras dices "hola" 1 s antes y 1 s después para capturar ejemplos
# Pulsa 'n' para marcar ruido/fondo
# Pulsa 'q' para terminar
Objetivo mínimo de dataset:
– 300 ejemplos “keyword”
– 600 ejemplos “noise”
– Total ~900 filas
Consejo: recoge en diferentes condiciones (distancias, ruidos, voces).
Entrenamiento y exportación de pesos
Crea train_export.py:
# File: train_export.py
import numpy as np
from sklearn.linear_model import LogisticRegression
DATASET = "dataset.csv"
OUT_H = "weights.h"
FEATURES = 325
# Carga CSV
rows = []
labels = []
with open(DATASET, "r") as f:
header = f.readline()
for line in f:
parts = line.strip().split(",")
label = parts[0]
feats = np.array([float(x) for x in parts[1:]], dtype=np.float32)
if feats.shape[0] != FEATURES:
continue
rows.append(feats)
labels.append(1 if label == "keyword" else 0)
X = np.vstack(rows)
y = np.array(labels, dtype=np.int32)
# Normalización simple por-feature (media 0, var 1)
mu = X.mean(axis=0)
sigma = X.std(axis=0) + 1e-6
Xn = (X - mu) / sigma
# Entrena logistic regression (L2, solver liblinear o saga)
clf = LogisticRegression(max_iter=1000, solver="liblinear")
clf.fit(Xn, y)
acc = clf.score(Xn, y)
print("Accuracy (train) =", acc)
w = clf.coef_[0].astype(np.float32)
b = float(clf.intercept_[0])
# Exporta a header C++ con normalización integrada: transformamos pesos a espacio original
# y = sigmoid( (x-mu)/sigma · w + b ) = sigmoid( x · (w/sigma) + (b - mu·(w/sigma)) )
ws = w / sigma
b_adj = b - (mu * ws).sum()
def as_c_array(arr, name):
s = f"static const float {name}[{len(arr)}] = {{\n"
line = ""
for i, v in enumerate(arr):
line += f"{v:.8e}f,"
if (i+1) % 10 == 0:
s += " " + line + "\n"
line = ""
if line:
s += " " + line + "\n"
s += "};\n"
return s
with open(OUT_H, "w") as f:
f.write("#pragma once\n")
f.write(f"#define KWS_FEATURES {FEATURES}\n\n")
f.write(as_c_array(ws, "KWS_WEIGHTS"))
f.write(f"\nstatic const float KWS_BIAS = {b_adj:.8e}f;\n")
print(f"Generado {OUT_H}")
Ejecución:
python train_export.py
# Reemplazará weights.h con pesos reales y sesgo ajustado
Restablece el firmware original (inferAndReport con probabilidad y disparos), compila y sube:
arduino-cli compile --fqbn arduino:samd:nano_33_iot ~/proyectos/i2s_kws_nano33iot
arduino-cli upload -p /dev/ttyACM0 --fqbn arduino:samd:nano_33_iot ~/proyectos/i2s_kws_nano33iot
arduino-cli monitor -p /dev/ttyACM0 -c baudrate=115200
Ahora la salida “p=…” variará y, cuando pronuncies “hola”, deberías ver “DETECCION: prob=…”.
Validación paso a paso
- Verificación física:
- Conexiones:
- VDD->3.3V, GND->GND
- INMP441 SCK->D2, WS->D3, SD->D4
- L/R->GND (canal izquierdo)
- Cables cortos y firmes.
- Inicialización:
- Al abrir el monitor serie a 115200, verás:
- “Init I2S KWS (Nano 33 IoT + INMP441)”
- “I2S listo. Capturando…”
- Nivel de ruido:
- Observa “p=…” en reposo: valores típicos alrededor de 0.1–0.4 si el modelo distingue silencio; si mal entrenado, ~0.5.
- Palabra clave:
- Pronuncia “hola” a 20–40 cm del micrófono; deberías ver “DETECCION: prob=0.80–0.99”.
- Repite varias veces para medir consistencia.
- Falsos positivos:
- Conversa sin decir “hola” o reproduce ruido; mide cuántas veces dispara por minuto (objetivo < 1/min).
- Robustez:
- Cambia distancia (10–80 cm), orientación del micrófono y presencia de ruido de fondo moderado.
- Latencia:
- El pipeline usa ~250 ms de contexto; la detección debería ocurrir en < 400 ms desde el inicio de la palabra.
Métricas sugeridas:
– Tasa de acierto con 50 ensayos deliberados de “hola”.
– Falsos positivos en 10 min de conversación sin la keyword.
– Probabilidad media al decir “hola” vs en silencio.
Troubleshooting
- No hay salida en el monitor serie
- Asegura el puerto correcto en arduino-cli monitor.
- Pulsa el botón RESET doble para entrar en bootloader y vuelve a subir.
- Verifica alimentación del USB y cable de datos (no solo carga).
- Error “no se pudo iniciar I2S”
- Revisa pines D2, D3, D4; evita cortocircuitos y cables sueltos.
- Quita otros dispositivos del bus que pudieran interferir.
- Reinicia la placa y el PC si el puerto USB quedó en mal estado.
- Probabilidades siempre ~0.5 o 0.0/1.0
- Verifica que weights.h fue regenerado y que el include apunta al archivo correcto.
- Asegura suficientes muestras y balance de clases (≥ 300 keyword, ≥ 600 noise).
- Revisa normalización integrada en train_export.py; no edites manualmente.
- Distorsión/recortes en audio
- Ajusta el shift de conversión (s >> 14). Si saturas, prueba >>15; si el volumen es bajo, prueba >>13.
- Asegura que L/R está a GND (o VDD) sólidamente; flotante puede introducir errores de canal.
- Ruido elevado o probabilidad inestable
- Usa cables más cortos y masa común robusta.
- Aísla de corrientes de aire y vibraciones (el INMP441 es sensible).
- Incrementa NUM_MEL a 26 y/o aplica media temporal de probabilidades.
- Subida falla con “No device found on port”
- Verifica que el puerto no cambió (/dev/ttyACM1).
- En Linux, añade tu usuario al grupo dialout: sudo usermod -aG dialout $USER y re‑inicia sesión.
- Memoria insuficiente o resets
- Reduce FFT_SIZE a 256 y ajusta FRAME_LEN a 320.
- Reduce NUM_FRAMES_STACK a 20 o NUM_MFCC a 10.
- Evita prints muy frecuentes; comenta telemetría en producción.
- Dataset inconsistente
- Alinea el tiempo: al etiquetar ‘k’, habla la keyword inmediatamente para capturar frames con señal.
- Graba en varias sesiones para mejorar generalización.
Mejoras/variantes
- Sustituir el clasificador logístico por:
- SVM lineal (exportable como vector de pesos).
- Red MLP de 1–2 capas pequeñas con activaciones ReLU, entrenada en Python y exportada como arrays; inferencia con CMSIS‑NN.
- Usar MFBE en vez de MFCC:
- Eliminar DCT (dct13); directo sobre log-mel a menudo da buenos resultados y reduce cómputo.
- TFLite Micro:
- Entrenar un modelo DSCNN pequeño y portarlo con Arduino_TensorFlowLite (asegúrate de tamaños de tensor ajustados a RAM del SAMD21).
- Filtrado adaptativo:
- VAD (Voice Activity Detection) por energía/ZCR antes de pasar a MFCC para reducir cargas y falsos positivos.
- BLE/IoT:
- Publicar eventos de detección por BLE (NINA-W102) o MQTT via WiFi para integración domótica.
- Optimización:
- Usar fixed‑point Q15 y CMSIS‑DSP para FFT/DCT.
- Bajar SAMPLE_RATE a 8 kHz para voces graves, ajustando filtros mel.
- Multi‑keyword:
- One‑vs‑rest con varios clasificadores logísticos o softmax con MLP.
Checklist de verificación
- [ ] Instalé Arduino CLI v0.35.3 y el core arduino:samd@1.8.14 sin errores.
- [ ] Instalé las librerías Arduino_I2S@1.0.3 y arduinoFFT@1.6.1.
- [ ] Conecté el INMP441 a 3.3V y GND del Nano 33 IoT.
- [ ] Cableé I2S: SCK->D2, WS->D3, SD->D4; L/R->GND.
- [ ] El firmware compila y sube con FQBN arduino:samd:nano_33_iot.
- [ ] El monitor serie muestra “I2S listo. Capturando…”.
- [ ] Puedo capturar MFCC con capture.py y etiquetar con ‘k’/‘n’.
- [ ] Entrené el clasificador y generé weights.h con train_export.py.
- [ ] Recompilé y subí el firmware con los nuevos pesos.
- [ ] Veo “DETECCION” con probabilidad > 0.75 al decir “hola”.
- [ ] Los falsos positivos están en niveles aceptables; ajusté TH_ON/TH_OFF si fue necesario.
Con esto, tendrás un pipeline completo de i2s‑keyword‑spotting sobre el Arduino Nano 33 IoT con micrófono INMP441, usando una toolchain reproducible y un flujo de trabajo de principio a fin (captura, entrenamiento, despliegue e inferencia en tiempo real).
Encuentra este producto y/o libros sobre este tema en Amazon
Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.




