Caso práctico: Detección de palabras clave

Caso práctico: Detección de palabras clave — hero

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

  1. 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:

  1. Compilar (Nano 33 IoT):
    bash
    arduino-cli compile \
    --fqbn arduino:samd:nano_33_iot \
    ~/proyectos/i2s_kws_nano33iot
  2. 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
  3. 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

  1. Verificación física:
  2. Conexiones:
    • VDD->3.3V, GND->GND
    • INMP441 SCK->D2, WS->D3, SD->D4
    • L/R->GND (canal izquierdo)
  3. Cables cortos y firmes.
  4. Inicialización:
  5. Al abrir el monitor serie a 115200, verás:
    • “Init I2S KWS (Nano 33 IoT + INMP441)”
    • “I2S listo. Capturando…”
  6. Nivel de ruido:
  7. Observa “p=…” en reposo: valores típicos alrededor de 0.1–0.4 si el modelo distingue silencio; si mal entrenado, ~0.5.
  8. Palabra clave:
  9. Pronuncia “hola” a 20–40 cm del micrófono; deberías ver “DETECCION: prob=0.80–0.99”.
  10. Repite varias veces para medir consistencia.
  11. Falsos positivos:
  12. Conversa sin decir “hola” o reproduce ruido; mide cuántas veces dispara por minuto (objetivo < 1/min).
  13. Robustez:
  14. Cambia distancia (10–80 cm), orientación del micrófono y presencia de ruido de fondo moderado.
  15. Latencia:
  16. 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

  1. No hay salida en el monitor serie
  2. Asegura el puerto correcto en arduino-cli monitor.
  3. Pulsa el botón RESET doble para entrar en bootloader y vuelve a subir.
  4. Verifica alimentación del USB y cable de datos (no solo carga).
  5. Error “no se pudo iniciar I2S”
  6. Revisa pines D2, D3, D4; evita cortocircuitos y cables sueltos.
  7. Quita otros dispositivos del bus que pudieran interferir.
  8. Reinicia la placa y el PC si el puerto USB quedó en mal estado.
  9. Probabilidades siempre ~0.5 o 0.0/1.0
  10. Verifica que weights.h fue regenerado y que el include apunta al archivo correcto.
  11. Asegura suficientes muestras y balance de clases (≥ 300 keyword, ≥ 600 noise).
  12. Revisa normalización integrada en train_export.py; no edites manualmente.
  13. Distorsión/recortes en audio
  14. Ajusta el shift de conversión (s >> 14). Si saturas, prueba >>15; si el volumen es bajo, prueba >>13.
  15. Asegura que L/R está a GND (o VDD) sólidamente; flotante puede introducir errores de canal.
  16. Ruido elevado o probabilidad inestable
  17. Usa cables más cortos y masa común robusta.
  18. Aísla de corrientes de aire y vibraciones (el INMP441 es sensible).
  19. Incrementa NUM_MEL a 26 y/o aplica media temporal de probabilidades.
  20. Subida falla con “No device found on port”
  21. Verifica que el puerto no cambió (/dev/ttyACM1).
  22. En Linux, añade tu usuario al grupo dialout: sudo usermod -aG dialout $USER y re‑inicia sesión.
  23. Memoria insuficiente o resets
  24. Reduce FFT_SIZE a 256 y ajusta FRAME_LEN a 320.
  25. Reduce NUM_FRAMES_STACK a 20 o NUM_MFCC a 10.
  26. Evita prints muy frecuentes; comenta telemetría en producción.
  27. Dataset inconsistente
  28. Alinea el tiempo: al etiquetar ‘k’, habla la keyword inmediatamente para capturar frames con señal.
  29. 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

Ir a 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.

Quiz rápido

Pregunta 1: ¿Cuál es el sistema operativo recomendado para el proyecto?




Pregunta 2: ¿Qué versión de Arduino CLI se debe instalar?




Pregunta 3: ¿Cuál es el modelo exacto del Arduino utilizado en el proyecto?




Pregunta 4: ¿Qué micrófono se utiliza para capturar audio?




Pregunta 5: ¿Cuál es la frecuencia de muestreo del audio capturado?




Pregunta 6: ¿Qué paquete de Python se utiliza para la regresión logística?




Pregunta 7: ¿Qué tipo de clasificador se entrena en Python?




Pregunta 8: ¿Qué se debe evitar al alimentar el micrófono INMP441?




Pregunta 9: ¿Qué librería se utiliza para el procesamiento de audio?




Pregunta 10: ¿Qué herramienta se utiliza para la inferencia en tiempo real en el Arduino?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:
Scroll to Top