Skip to Content

Controllo vocale con XIAO-ESP32-S3-Sense e Edge Impulse

Controllo vocale con XIAO-ESP32-S3-Sense e Edge Impulse

In questo tutorial imparerai come creare un’applicazione di controllo vocale con la scheda XIAO-ESP32-S3-Sense e la piattaforma Edge Impulse. Prima raccoglieremo dati audio con il microfono della XIAO-ESP32-S3-Sense. Poi caricheremo questi dati audio sulla piattaforma Edge Impulse e alleneremo un modello di riconoscimento parole chiave. Infine, distribuiremo questo modello TinyML sulla XIAO-ESP32-S3-Sense, che ci permetterà di controllare i LED con la voce.

Il modello TinyML sarà addestrato a riconoscere le parole “red”, “yellow” e “green”. Collegheremo tre LED (rosso, giallo, verde) alla scheda XIAO-ESP32-S3-Sense, che potrai accendere tramite comandi vocali. Guarda il sistema in azione qui sotto:

Controllo Vocale dei LED con XIAO-ESP32-S3-Sense

Se non hai mai usato la XIAO-ESP32-S3 Sense prima, dai un’occhiata al Getting started with XIAO-ESP32-S3-Sense tutorial prima, altrimenti iniziamo…

Componenti Necessari

Ti servirà una scheda XIAO ESP32 S3 Sense per provare gli esempi di codice. Nota che la scheda può scaldarsi e potresti voler applicare un piccolo Heatsink sul retro (vedi il componente elencato sotto).

Inoltre, ti servirà una scheda SD per memorizzare i campioni audio raccolti. Ho indicato una scheda da 32 GB, ma va bene anche una più piccola (8GB). Infine, se il tuo computer non ha un lettore di schede SD integrato, ti servirà anche uno esterno.

Seeed Studio XIAO ESP32 S3 Sense

Cavo USB C

Piccolo dissipatore 9×9 mm

Scheda SD 32GB

Lettore di schede 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 scheda XIAO-ESP32-S3-Sense

La XIAO-ESP32-S3-Sense è basata sull’ESP32-S3, un chip di Espressif progettato per AI e calcolo edge. La scheda è composta da quattro parti: la scheda principale, il Sense Hat, la fotocamera e un’antenna Wi-Fi esterna:

Parts of the XIAO-ESP32-S3 Sense
Componenti della XIAO-ESP32-S3 Sense

Il Sense Hat si collega alla scheda principale e dispone di uno slot per scheda SD, uno per la fotocamera e un microfono. L’immagine sotto mostra la XIAO-ESP32-S3-Sense completamente assemblata con una scheda SD inserita:

Assembled XIAO-ESP32-S3-Sense board with Camera, Antenna and SD Card
Scheda XIAO-ESP32-S3-Sense assemblata con fotocamera, antenna e scheda SD

Per questo progetto non useremo l’antenna WiFi né la fotocamera. Quindi la configurazione mostrata sotto è sufficiente e non avrai nemmeno bisogno della scheda SD, una volta raccolti i campioni audio.

XIAO-ESP32-S3-Sense with SD Card
XIAO-ESP32-S3-Sense con scheda SD

Nota che l’alimentazione della scheda può essere fornita sia tramite la porta USB Type-C sia tramite l’interfaccia di ricarica batteria collegabile a una batteria LiPo da 3,7V.

Microfono della XIAO-ESP32-S3-Sense

La XIAO-ESP32-S3-Sense è dotata di un microfono digitale integrato MEMS Microphone di tipo MSM261D3526H1CPM situato sul Sense Hat. Vedi l’immagine sotto:

Microphone on Sense Hat
Microfono sul Sense Hat

Puoi comunicare con il microfono tramite due linee di segnale (PDM_CLK, PDM_DATA) usando I2S protocol collegate a IO42 e IO41 come mostrato nello schema sotto:

Schematics for Microphone on Sense Hat
Schema del microfono sul Sense Hat

Per informazioni più dettagliate consulta il nostro Record Audio with XIAO-ESP32-S3-Sense tutorial.

Pinout della scheda XIAO-ESP32-S3-Sense

Infine, diamo un’occhiata al pinout della scheda XIAO-ESP32-S3-Sense:

Pinout of XIAO-ESP32-S3-Sense board
Pinout della scheda XIAO-ESP32-S3-Sense (source)

Il pin 5V è il 5V proveniente dalla porta USB. Il pin 3V3 fornisce l’uscita dal regolatore onboard e può erogare fino a 700mA. Il pin GND è la massa.

Per quanto riguarda i GPIO: la scheda offre 11 GPIO digitali/analogici ma GPIO0, GPIO3, GPIO43 e GPIO44 sono pin di strapping che devono essere in uno stato specifico all’avvio – quindi fai attenzione a questi. Una volta avviato il microcontrollore, i pin di strapping funzionano come normali pin IO.

Se ti serve aiuto, consulta il Getting started with XIAO-ESP32-S3-Sense tutorial.

Formattare la scheda SD per la raccolta audio

Prima di iniziare a raccogliere campioni audio per addestrare il nostro modello TinyML, dobbiamo assicurarci che la scheda SD sia formattata correttamente.

Inserisci la scheda SD nel lettore di schede SD, che può essere integrato nel tuo computer o esterno (elencato tra i componenti necessari).

Apri Esplora risorse (su Windows), cerca la nuova unità USB e fai clic destro per aprire il menu. Seleziona “Mostra altre opzioni” e poi “Formatta…” per aprire la finestra di formattazione:

Menu to Format SD Card
Menu per formattare la scheda SD

Verifica che il file system sia impostato su “FAT32” e premi “Avvia”.Assicurati di aver selezionato l’unità USB corretta, perché la formattazione cancellerà tutti i dati esistenti su quella unità!

Di solito rinomino l’unità, ad esempio in “SAMPLES”, per essere sicuro di aver selezionato l’unità giusta e non formattare accidentalmente un’altra.

Raccolta campioni audio con XIAO-ESP32-S3-Sense

Ora scriviamo il codice per raccogliere i campioni audio necessari ad addestrare il nostro modello TinyML per il controllo vocale. Assicurati di aver formattato e inserito correttamente la scheda SD nella XIAO-ESP32-S3-Sense, come mostrato sotto:

SD Card inserted into XIAO-ESP32-S3-Sense
Scheda SD inserita nella XIAO-ESP32-S3-Sense

Vogliamo usare la voce per accendere un LED rosso, giallo o verde. Le parole di controllo saranno quindi “red”, “yellow” e “green”, anche se potresti scegliere altre parole in qualsiasi lingua ti piaccia.

Red, yellow, green LEDs
LED rossi, gialli, verdi

Potresti raccogliere i campioni audio usando il microfono del telefono o del computer, ma le caratteristiche (suono) di questi microfoni saranno diverse da quello della XIAO-ESP32-S3-Sense. È quindi meglio usare il microfono della XIAO-ESP32-S3-Sense, ma serve un po’ di codice per registrare i campioni audio e salvarli sulla scheda SD.

Il codice seguente raccoglie i campioni audio. Devi inserire l’etichetta (“red”, “yellow” o “green”) nel Monitor Seriale e premere invio.

Questo avvierà una registrazione di 1 secondo, durante la quale pronunci la parola di controllo (es. “red”). La registrazione viene poi salvata sulla scheda SD come campione audio. Dai un’occhiata al codice completo prima di discuterne i dettagli:

#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); 
}

Il primo passo del programma è includere le librerie necessarie. Queste librerie permettono all’ESP32 di usare l’interfaccia I2S per l’ingresso microfono e di salvare file su scheda SD. Senza di esse, l’ESP32 non potrebbe registrare o salvare audio.

#include "ESP_I2S.h"
#include "FS.h"
#include "SD.h"

Costanti

Il codice definisce una costante per la dimensione dell’header del file WAV. Ogni file WAV inizia con un header di 44 byte che contiene informazioni come frequenza di campionamento, profondità bit e numero di canali. Definendo header_size, il codice sa dove iniziano i dati audio veri e propri.

const int header_size = 44;

Oggetti

Il programma crea un oggetto I2SClass che gestisce tutta la comunicazione con il microfono tramite il protocollo I2S. Creando questa istanza, l’ESP32 può inizializzare il microfono, impostarne i parametri e registrare audio.

I2SClass i2s;

scaleVolume

La funzione scaleVolume() regola il volume dell’audio registrato. Prende i campioni audio grezzi, li moltiplica per un fattore di guadagno e poi limita i valori all’intervallo audio a 16 bit. Questo evita distorsioni dovute a overflow.

void scaleVolume(int16_t* audioData, size_t sampleCount) {
  const float gain = 16.0;
  for (size_t i = 0; i < sampleCount; i++) {
    audioData[i] = (int16_t)constrain(audioData[i] * gain, INT16_MIN, INT16_MAX);
  }
}

Potresti anche normalizzare il volume (un fattore di scala tipico che ho osservato è 20x) ma questo non funziona con lo streaming e amplifica il rumore. Comunque è utile per farsi un’idea di un guadagno adatto, ad esempio 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 funzione setup() prepara tutto prima che inizi il loop principale. Prima inizializza il monitor seriale per il debug. Poi configura i pin del microfono con i2s.setPinsPdmRx(42, 41) e avvia l’interfaccia I2S a 16 kHz in modalità mono. Se non viene rilevato alcun microfono, stampa un messaggio di errore. Successivamente inizializza la scheda SD sul pin 21. Se la scheda SD non si monta, stampa un altro messaggio di errore. Infine, l’ESP32 chiede all’utente di inserire un’etichetta tramite il monitor seriale.

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

Nel loop(), il programma attende l’input dell’utente dal monitor seriale. L’utente può digitare un’etichetta, come “red” o “noise”. Quando viene inserita un’etichetta, il codice crea una cartella sulla scheda SD con quel nome. Questa organizzazione facilita la separazione delle registrazioni e semplifica il caricamento sulla piattaforma 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);
    }
  }

Dopo aver impostato l’etichetta, il programma genera un nome file per la nuova registrazione. Usa il formato label/label_number.wav. Per esempio, se l’etichetta è “red”, il primo file sarà red/red_1.wav. Ogni nuova registrazione incrementa il contatore, così i file sono salvati in ordine senza sovrascrivere.

sprintf(filename, "/%s/%s_%d.wav", label.c_str(), label.c_str(), cnt++);
Serial.printf("Recording to: %s\n", filename);

Per registrare l’audio, il codice chiama i2s.recordWAV(1, &wav_size). Questa funzione cattura un secondo di audio e lo memorizza in memoria come file WAV. Se la registrazione contiene dati audio validi, il programma applica la funzione scaleVolume() per aumentare il guadagno dei campioni 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 volta processato l’audio, il programma apre un file sulla scheda SD e vi scrive i dati WAV. Se il file non può essere aperto o scritto, stampa un messaggio di errore. Dopo aver salvato la registrazione, il file viene chiuso e viene stampato un messaggio di conferma.

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.");

Infine, il loop attende 100 millisecondi prima di controllare di nuovo l’input utente. Questa piccola pausa evita che l’ESP32 sovraccarichi l’elaborazione dell’input seriale.

delay(100);

Questo progetto trasforma il tuo ESP32 in un pratico registratore audio che cattura clip di un secondo da un microfono I2S e li salva come file WAV su scheda SD. Con cartelle etichettate, diventa particolarmente utile per creare dataset per progetti di machine learning, come l’addestramento di un modello di riconoscimento vocale.

Esecuzione del codice per raccogliere campioni

Carica il codice sulla tua XIAO-ESP32-S3-Sense e apri il monitor seriale. Inserisci l’etichetta per la classe di cui vuoi raccogliere campioni nella casella di testo, ad esempio “yellow”, e premi Invio.

Recording Audio Samples and Serial Monitor Output
Registrazione campioni audio e output del monitor seriale

Il monitor seriale mostrerà il nome del file per il campione audio, ad esempio “/yellow/yellow_1.wav” e avrai un secondo per pronunciare la parola “yellow”. Al termine, il testo “done.” verrà stampato sul monitor seriale. Qui sotto un esempio di campione audio registrato per la parola “yellow”:

Per le registrazioni successive della stessa classe, ad esempio “yellow”, basta premere Invio. Non inserire di nuovo la stessa etichetta. Quando sei pronto per registrare i campioni per la classe successiva, ad esempio “red”, inserisci “red”, premi Invio e continua a registrare allo stesso modo.

Il codice creerà una nuova cartella per ogni classe (“red”, “green”, “yellow”, “noise”) e salverà i campioni audio all’interno di essa:

Folder Structure for Audio Samples
Struttura delle cartelle per i campioni audio

Ho registrato 50 campioni per colore e 100 per il rumore. Per la classe “noise”: registra silenzio, rumori ambientali, musica, discorsi, tutto tranne le parole “red”, “green” o “yellow”. Più campioni hai per ogni classe, meglio è, ma con 50 campioni si ottiene già una buona precisione di rilevamento – non ottima, ma buona ; )

Addestramento del modello di controllo vocale con Edge Impulse

In questa sezione vedremo i passaggi necessari per caricare i dati di addestramento, creare la pipeline di preprocessing e modello (impulse) e addestrare il nostro modello TinyML.

Crea un nuovo progetto Edge Impulse, ad esempio chiamandolo “Voice Control”, e poi siamo pronti a caricare i dati di addestramento.

Carica dati di addestramento

Prima, rimuovi la scheda SD dalla XIAO-ESP32-S3-Sense e collegala al computer, così potremo caricare i dati da essa. Apparirà come un’unità USB, se usi un lettore di schede SD esterno.

Poi clicca su “Data acquisition” -> “+ Add data” -> “Upload data” per aprire la finestra di caricamento dati:

Nella finestra seleziona “Select a folder” e inserisci l’etichetta della classe per cui vuoi caricare i dati, ad esempio “red”, in basso:

Ora clicca su “Choose files” e seleziona la cartella “red” con i tuoi campioni audio. Poi clicca su “Upload data” e vedrai i dati caricarsi:

Ripeti questo processo per gli altri colori e per la classe rumore. Assicurati di inserire l’etichetta corretta sotto “Enter label” prima di caricare. Comunque, potrai cancellare in seguito i campioni caricati con etichetta sbagliata.

Quando hai finito, chiudi la finestra cliccando sulla x in alto a destra:

Esplora dati

Dovresti ora vedere il dataset nel Data explorer. In alto a sinistra vedrai un grafico a torta che rappresenta la distribuzione delle classi e la divisione Train/Test. Sotto c’è il Dataset, dove puoi cliccare sui singoli campioni per ascoltarli:

Se trovi qualcosa di sbagliato nei dati, ci sono funzioni per cancellare i campioni. Se invece sei soddisfatto, il passo successivo è creare un Impulse (preprocessing dati + modello).

Crea Impulse

Clicca su “Create impulse” sotto “Impulse design”:

Poi aggiungi tre blocchi: “Time series data”, “Audio (MFE)” e “Transfer Learning (Keyword Spotting)”:

Mantieni tutte le impostazioni di default per i blocchi così come sono. Nelle sezioni successive configureremo e alleneremo i singoli blocchi.

MFE

Clicca su “MFE” sotto “Impulse design”:

Si aprirà la finestra di impostazioni per il blocco MFE:

MFE sta per Mel Frequency Energy ed è un metodo di elaborazione digitale del segnale per estrarre caratteristiche da un segnale audio. Manteniamo le impostazioni del MFE così come sono. Premi “Save parameters” per salvarle.

Nel Feature explorer potrai vedere quanto bene queste caratteristiche estratte funzionano:

Ogni punto nel grafico rappresenta uno dei nostri campioni audio (una parola pronunciata o rumore). Idealmente i punti della stessa classe dovrebbero essere raggruppati strettamente e i gruppi ben separati.

Il grafico mostra un certo raggruppamento ma non è ottimale. Si vede che i gruppi per le parole colore (“red”, “green”, “yellow”) sono allungati. Probabilmente perché variavo la distanza dal microfono mentre parlavo.

La classe “noise”, invece, ha una forma circolare, poiché la maggior parte dei campioni di rumore sono simili per volume ma diversi nel contenuto.

Basandomi su questo clustering, non mi aspetterei un’accuratezza di riconoscimento fantastica. Puoi provare a modificare le impostazioni del blocco MFE e raccogliere più campioni, cosa che dovrebbe rendere il modello più robusto.

Transfer Learning

Per addestrare il modello clicca su “Transfer learning” sotto “Impulse design”:

Si aprirà la finestra di impostazioni della rete neurale mostrata sotto:

Puoi scegliere tra due modelli: MobileNetV1 0.1 o MobileNetV2 0.35. Il secondo è probabilmente più accurato ma anche molto più grande e non sono riuscito a farlo funzionare sulla XIAO-ESP32-S3-Sense. Ho quindi scelto il modello MobileNetV1 0.1.

Keyword Spotting Models
Modelli di Keyword Spotting

Ho aumentato il numero di cicli di addestramento a 60, impostato il learning rate a 0.01 e mantenuto la CPU come processore di addestramento. Tuttavia, nessuno di questi parametri è particolarmente sensibile o importante per l’accuratezza finale del modello e le impostazioni di default dovrebbero andare bene.

Poi clicca su “Save & Train”, che addestrerà il modello. Al termine vedrai una matrice di confusione e altre metriche di performance della rete stampate:

Come puoi vedere, nel mio caso l’accuratezza complessiva è stata del 95% e il modello tendeva a confondere le parole colore con il rumore, cosa prevedibile. L’accuratezza probabilmente potrebbe migliorare con più campioni di addestramento, ma è un modello molto piccolo e la sua capacità di riconoscimento è quindi limitata.

Distribuzione del modello

Infine, dobbiamo distribuire il modello per eseguirlo sulla XIAO-ESP32-S3-Sense. Clicca su “Deployment” sotto “Impulse design”:

In alto a destra puoi selezionare il processore target per la distribuzione. A partire da agosto 2025, Edge Impulse non supporta direttamente la XIAO-ESP32-S3-Sense. Selezioniamo invece l’Espressif ESP-EYE:

Cliccandoci si apre la finestra di configurazione per il dispositivo target:

Poi sistemeremo il codice distribuito per farlo funzionare con la XIAO-ESP32-S3-Sense. Per il tipo di distribuzione selezioniamo “Arduino library” e “TensorFlow Lite”:

Clicca sul pulsante build per compilare e distribuire il modello:

Scaricherà il modello come file ZIP, nel mio caso: “ei-voice-control-arduino-1.0.8.zip“. Il nome del file ZIP dipenderà dal nome scelto per il progetto Edge Impulse (“Voice Control”).

Per usare la libreria, crea uno Sketch nuovo e vuoto e poi installa la libreria scaricata (ei-voice-...zip) come al solito nell’IDE Arduino tramite Sketch -> Include Library -> Add .ZIP library.

Collegare i LED alla XIAO-ESP32-S3-Sense

Prima di scrivere il codice per controllare i LED, ti mostro rapidamente come collegare i tre LED alla XIAO-ESP32-S3-Sense.

Collegamento dei LED alla XIAO-ESP32-S3-Sense

Il catodo di tutti e tre i LED è collegato a massa (GND). Gli anodi dei LED sono collegati ai pin GPIO 1, 2 e 3 tramite una resistenza da 220Ω come mostrato sopra. Puoi scegliere altri pin GPIO, ma in tal caso assicurati di modificare il codice di conseguenza.

Codice per il controllo vocale dei LED

La libreria ei-voice-control-arduino-1.0.8.zip che abbiamo distribuito include del codice di esempio, ma non possiamo usarlo perché è per l’ESP-EYE, che ha un’interfaccia microfono diversa dalla XIAO-ESP32-S3-Sense.

Ho quindi preso il codice di esempio e l’ho modificato per funzionare con la XIAO-ESP32-S3-Sense. Ho anche aggiunto il codice per accendere i LED.

Il codice seguente ascolta il microfono, invia il segnale audio registrato al modello classificatore, recupera il risultato della classificazione e accende brevemente il LED rosso, giallo o verde se una parola colore è stata riconosciuta con sufficiente confidenza.

Dai un’occhiata al codice completo prima, poi ne discutiamo i dettagli. Assicurati di sostituire l’importazione #include "Voice_control_inferencing.h", con il nome della tua libreria distribuita!

#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 = &microphone_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);
  }
}

Importazioni

Le prime righe importano le librerie necessarie. Ognuna ha un ruolo importante nel riconoscimento vocale e nel controllo hardware.

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

Il file Voice_control_inferencing.h è generato da Edge Impulse e contiene la rete neurale addestrata. Come detto, questo nome dipenderà dal nome del progetto Edge Impulse e della libreria distribuita. Potrebbe essere necessario modificarlo.

La libreria ESP_I2S.h fornisce accesso all’interfaccia microfono I2S e le intestazioni FreeRTOS ci permettono di eseguire la cattura audio come task in background.

Costanti e buffer

Definiamo strutture e costanti per gestire la registrazione audio e la classificazione.

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 struttura inference_t contiene il buffer audio e tiene traccia di quanti campioni abbiamo. Allochiamo sampleBuffer come spazio temporaneo per l’input del microfono. Il flag record_status controlla se continuiamo a registrare.

Pin microfono e frequenza di campionamento

Definiamo quali pin ESP32 sono collegati al microfono e la frequenza di campionamento.

static const int8_t I2S_CLK = 42;
static const int8_t I2S_DIN = 41;
static const uint32_t SAMPLERATE = 16000;

Il microfono si collega tramite I2S. Il pin 42 fornisce il segnale di clock, il pin 41 è l’ingresso dati. Campioniamo a 16 kHz, perfetto per il riconoscimento vocale.

Pin LED

Assegniamo tre pin per il feedback visivo.

static const int8_t redPin = 1;
static const int8_t yellowPin = 2;
static const int8_t greenPin = 3;

Ogni pin controlla un LED. Lampeggeranno quando il classificatore rileva la parola colore corrispondente.

Callback di inferenza audio

Questa funzione sposta i campioni dal buffer temporaneo a quello principale per l’inferenza.

static void audio_inference_callback(uint32_t n_samples) {
  for (uint32_t i = 0; i < n_samples; i++) {
    inference.buffer[inference.buf_count++] = sampleBuffer[i];

    if (inference.buf_count >= inference.n_samples) {
      inference.buf_count = 0;
      inference.buf_ready = 1;
    }
  }
}

Quando il buffer è pieno, lo segnaliamo come pronto per il classificatore.

Cattura campioni microfono

Invece di registrare l’audio nel loop principale, eseguiamo questo task in background.

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);
}

Leggiamo continuamente campioni dal microfono usando I2S.read(). Ogni campione viene amplificato con un fattore di guadagno prima di essere salvato. Quando raccogliamo abbastanza campioni, li passiamo all’inferenza.

Avvio inferenza microfono

Questa funzione inizializza il microfono e avvia il task di cattura.

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;
}

Allochiamo memoria per il buffer di inferenza e configuriamo I2S per microfoni PDM. Se tutto funziona, un task FreeRTOS inizia a catturare campioni in background.

Attesa nuovo audio

Questa funzione aspetta finché il buffer non è pronto con nuovi dati.

static bool microphone_inference_record(void) {
  while (inference.buf_ready == 0) delay(10);
  inference.buf_ready = 0;
  return true;
}

Il codice si mette in pausa finché non sono raccolti abbastanza dati audio per la classificazione.

Conversione dati audio

Il classificatore Edge Impulse si aspetta campioni audio in virgola mobile. Questa funzione converte i dati grezzi a 16 bit in float.

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;
}

Arresto microfono

Quando non serve più l’audio, fermiamo I2S e liberiamo la memoria.

static void microphone_inference_end(void) {
  I2S.end();
  free(inference.buffer);
}

Funzioni di controllo LED

Prepariamo funzioni di supporto per controllare i LED.

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() configurerà i pin come uscite.switchOffLEDs() spegnerà tutti i LED.flashLED() accende il LED corrispondente all’etichetta riconosciuta e lo spegne dopo mezzo secondo.

Setup

Nella funzione setup inizializziamo il monitor seriale, i LED e il microfono.

void setup() {
  Serial.begin(115200);
  
  initLEDs();

  if (!microphone_inference_start(EI_CLASSIFIER_RAW_SAMPLE_COUNT)) {
    ei_printf("ERR: Could not allocate audio buffer\r\n");
    return;
  }
  ei_printf("Listening...\n");
}

Se il microfono parte correttamente, siamo pronti a catturare l’input vocale.

Loop

Il loop gira continuamente per processare l’audio e classificare i comandi vocali.

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 = &microphone_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);
  }
}

Aspettiamo un nuovo buffer audio, prepariamo un oggetto segnale e lanciamo il classificatore. Il classificatore restituisce etichette con probabilità. Ignoriamo l’etichetta "noise" e scegliamo la classe con la confidenza più alta. Se la probabilità è superiore a 0.3, lampeggiamo il LED corrispondente.

Questo codice funziona bene ma reagisce un po’ lentamente ai comandi vocali, perché dobbiamo aspettare che la finestra di inferenza sia piena. Il codice streaming seguente è più veloce ma la precisione di classificazione è leggermente inferiore.

Codice streaming per controllo vocale

Il classificatore continuo nel codice seguente è progettato per gestire finestre scorrevoli di audio. Processa porzioni sovrapposte di input, così non serve fermare e riavviare l’inferenza ogni volta. Questo rende il sistema più reattivo alle parole chiave (ma la precisione cala un po’). Vedi la demo seguente:

Dai un’occhiata al codice completo e poi analizziamo le differenze con il codice precedente.

#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 = &microphone_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;
  }
}

Direttiva del preprocessore

In cima, questo codice introduce una nuova definizione:

#define EIDSP_QUANTIZE_FILTERBANK 0

Disabilita la quantizzazione del filtro nel pipeline DSP di Edge Impulse. Il modello usa precisione completa invece di valori compressi. La prima versione non includeva questa riga, quindi usava la quantizzazione di default.

Importazioni

Le importazioni sono le stesse di prima: codice di inferenza Edge Impulse, supporto I2S e FreeRTOS per multitasking. Come prima, il nome della libreria Voice_control_inferencing.h dipenderà dal nome del progetto Edge Impulse e della libreria distribuita. Potrebbe essere necessario modificarlo.

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

Buffer e variabili

Invece di creare un grande buffer di inferenza, questa versione lavora con porzioni di audio.

static const uint32_t slice_size = EI_CLASSIFIER_SLICE_SIZE;
static signed short sampleBuffer[slice_size];
static int slice_ctn = 0;

Nel codice precedente, il buffer conteneva un’intera finestra di campioni (EI_CLASSIFIER_RAW_SAMPLE_COUNT). Qui si processa solo una porzione alla volta. La variabile slice_ctn aiuta a limitare le predizioni per non attivarle troppo spesso.

Task di cattura audio

Il ciclo di cattura è simile ma con una grande differenza:

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);
}

Qui riempiamo continuamente solo una porzione invece di una finestra completa. La chiamata vTaskDelay(1) permette a FreeRTOS di gestire meglio altri task. La prima versione inseriva campioni nel buffer di inferenza con callback, questa aggiorna solo il buffer della porzione.

Setup microfono

Entrambe le versioni configurano il microfono con I2S, ma questa è più semplice.

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;
}

Notare che non c’è allocazione dinamica di memoria (malloc) né setup del buffer di inferenza. Questo riduce la complessità ed evita frammentazione di memoria.

Registrazione audio

La funzione di registrazione ora è un segnaposto:

static bool microphone_inference_record(void) {
  delay(1);
  return true;
}

Nella prima versione questa funzione aspettava che il buffer fosse pronto. Qui non serve. La classificazione avviene porzione per porzione.

Input del classificatore

La funzione per convertire i campioni grezzi in float è la stessa in entrambe le versioni:

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;
}

LED

Le funzioni LED sono identiche: accendono il LED dell’etichetta riconosciuta e lo spengono dopo mezzo secondo.

void flashLED(const char* label) { ... }

Funzione setup

La funzione setup ha qualche passo nuovo:

run_classifier_init();
ei_sleep(1000);

Il classificatore viene inizializzato una volta prima dell’uso, necessario per la classificazione continua. La versione precedente non lo richiedeva perché eseguiva inferenze singole.

Il microfono parte anche con slice_size invece del conteggio completo dei campioni grezzi, abilitando lo streaming continuo.

Funzione loop

Qui sta la differenza più grande. Invece di usare run_classifier(), questa versione chiama:

EI_IMPULSE_ERROR r = run_classifier_continuous(&signal, &result, debug_nn);

Il classificatore continuo gestisce finestre scorrevoli di audio. Processa porzioni sovrapposte di input, così non serve fermare e riavviare l’inferenza ogni volta. Questo rende il sistema più reattivo alle parole chiave.

Un’altra differenza chiave è come vengono limitate le predizioni:

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;
}

Dopo aver rilevato un’etichetta, il programma aspetta che passi una finestra completa del modello prima di permettere un altro trigger. Questo evita che il LED lampeggi più volte per la stessa parola. Il codice precedente non aveva questo meccanismo, quindi poteva riattivarsi subito.

Riepilogo delle differenze

La prima versione usa un approccio bufferizzato e one-shot:

  • Raccoglie una finestra completa di inferenza.
  • Esegue la classificazione una volta per buffer.
  • Deve aspettare che il buffer sia pronto.
  • Usa run_classifier().

Questa seconda versione usa un approccio streaming e continuo:

  • Lavora con piccole porzioni di audio.
  • Esegue la classificazione continuamente con sovrapposizione.
  • Non aspetta che il buffer sia pronto.
  • Usa run_classifier_continuous().
  • Aggiunge un cooldown (slice_ctn) per evitare trigger ripetuti.

Conclusioni e commenti

In questo tutorial hai imparato come costruire un’applicazione di controllo vocale con la scheda XIAO-ESP32-S3-Sense e la piattaforma Edge Impulse. Edge Impulse offre molto di più di quanto trattato qui e ti consiglio di leggere il Edge Impulse Documentation.

L’esempio semplice di questo tutorial ti ha permesso di controllare tre LED con la voce. Nota che puoi creare applicazioni di controllo più potenti usando una sequenza di parole e una macchina a stati. Per esempio, una parola di attivazione come “Jarvis”, seguita da una posizione “Desk”, “Corner”, “Shelf”, seguita da un dispositivo “Light” o “Fan”, seguita da un’azione “On” o “Off”:

[Jarvis] -> [Desk, Corner, Shelf] -> [Light, Fan] -> [On, Off]

Con otto parole diverse puoi già controllare 1x3x2x2 = 12 azioni diverse come “Jarvis Desk Light On”

Oltre all’automazione domestica, ci sono molte altre applicazioni interessanti della classificazione sonora. Pensa alla classificazione dei suoni ambientali, al rilevamento di anomalie da vibrazioni/suoni, alla classificazione del canto degli uccelli e così via.

Infine, se vuoi rilevare volti o persone dai un’occhiata ai nostri Face Detection with XIAO ESP32-S3-Sense and SenseCraft AI e Edge AI Room Occupancy Sensor with ESP32 and Person Detectiontutorial.

Se hai domande, sentiti libero di lasciarle nella sezione commenti.

Buon divertimento con il tinkering 😉