Assistente de Bancada com Wake Word e Voz Local: ESP32-S3 + ESP-SR/WakeNet + Microfone I2S

Ouvir

Carregando voz…

Assistente de Bancada com Wake Word e Voz Local: ESP32-S3 + ESP-SR/WakeNet + Microfone I2S

Este é o artigo de montagem da série Assistentes de IA de Bancada. Aqui o foco é o caminho mais longo e mais recompensador: um assistente que acorda com uma palavra-chave detectada localmente, no próprio ESP32-S3, sem depender de nuvem para ouvir. Se você ainda não viu o panorama dos repositórios, comece por ele — este texto assume aquela base e desce ao hardware, à pinagem e ao firmware.

(a) Conceito e Arquitetura

O pipeline tem seis estágios. O microfone MEMS entrega amostras por I2S; o AFE do ESP-SR faz a limpeza do sinal (cancelamento de eco, supressão de ruído e controle automático de ganho); o WakeNet observa continuamente esse fluxo e dispara quando reconhece a palavra-chave. A partir daí o áudio do comando é bufferizado e enviado — via Wi-Fi — para um backend de ASR/LLM (nuvem ou Ollama local). A resposta volta em texto, passa por TTS e sai pelo amplificador I2S.

O ponto crítico é que os três primeiros estágios rodam 100% no MCU. A nuvem só entra depois do wake word, o que reduz consumo, custo e exposição de dados. É aqui que este projeto se diferencia de um assistente de voz genérico: o objetivo não é conversar abertamente o tempo todo, e sim ter um companheiro de bancada que responde quando chamado, com latência de gatilho abaixo de 100 ms.

(b) BOM

Componente (modelo) Função Faixa de preço (BRL)
ESP32-S3-DevKitC-1 (N16R8, 16 MB flash / 8 MB PSRAM) MCU principal: Wi-Fi/BLE, PSRAM para AFE + WakeNet R$ 60–120
INMP441 Microfone MEMS I2S 24-bit, 60 Hz–15 kHz R$ 15–30
MSM261S4030H0 (alternativa) Microfone I2S usado em devkits Espressif R$ 20–40
MAX98357A Amplificador I2S classe D, 3,2 W R$ 20–35
Alto-falante 4 Ω / 3 W Saída de áudio (TTS/retorno) R$ 10–25
Display ST7789 1,3″ SPI (ou ILI9341) Feedback visual / UI LVGL (nível de áudio, status) R$ 35–70
Botão tátil + LED Push-to-talk e status de estado R$ 2–5
Protoboard / PCB + jumpers Montagem e cabeamento R$ 15–30

(c) Wiring & Esquemático

Sinal Periférico GPIO (ESP32-S3) Observações
VDD INMP441 3V3 Fonte 3,3 V dedicada; adicione 100 nF perto do pino
GND INMP441 GND Referência comum
SCK (BCLK) INMP441 GPIO14 Clock de bits, gerado pelo ESP32 (master)
WS (LRCLK) INMP441 GPIO15 Seleção de canal/word select
SD (DIN) INMP441 GPIO32 Dados do mic para o MCU
L/R INMP441 GND GND = canal esquerdo
VIN MAX98357A 5V Use o rail 5 V, não o 3V3, em volume alto
BCLK MAX98357A GPIO26 Clock de bits do I2S de saída
LRC MAX98357A GPIO25 Word select de saída
DIN MAX98357A GPIO22 Dados do MCU para o amp
SD MAX98357A 3V3 Enable; em nível baixo o amp silencia
CS / DC / RST ST7789 GPIO5 / GPIO4 / GPIO2 Barramento SPI do display

Cuidados elétricos. Wi-Fi somado a PSRAM produz picos de corrente: use fonte de 5 V/2 A e um capacitor de 470 µF–1000 µF no rail de 3,3 V. Não alimente o amplificador pelo 3V3 do devkit se for operar em volume alto. Todos os periféricos aqui são de nível lógico 3,3 V, então não há divisor de nível. Para o botão, basta o pull-up interno do MCU (ou 10 kΩ externo). Atenção aos strapping pins do ESP32-S3 — GPIO0, GPIO3, GPIO45 e GPIO46: não deixe nenhum deles puxado para o nível errado no boot, pois isso altera o modo de inicialização. No I2S, mantenha o BCLK curto e longe das trilhas de RF; o INMP441 não precisa de MCLK, apenas BCLK e WS.

(d) Código e Firmware

Inicialização do I2S de captura (ESP-IDF 5.x):

#include "driver/i2s_std.h"

i2s_chan_handle_t rx_chan;

void i2s_mic_init(void) {
    i2s_chan_config_t chan_cfg =
        I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
    chan_cfg.dma_desc_num  = 6;
    chan_cfg.dma_frame_num = 240;      // ~15 ms a 16 kHz
    i2s_new_channel(&chan_cfg, NULL, &rx_chan);

    i2s_std_config_t std_cfg = {
        .clk_cfg  = I2S_STD_CLK_DEFAULT_CONFIG(16000),
        .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(
                        I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_MONO),
        .gpio_cfg = {
            .mclk = I2S_GPIO_UNUSED,
            .bclk = GPIO_NUM_14,
            .ws   = GPIO_NUM_15,
            .dout = I2S_GPIO_UNUSED,
            .din  = GPIO_NUM_32,
        },
    };
    std_cfg.slot_cfg.slot_mask = I2S_STD_SLOT_LEFT;
    i2s_channel_init_std_mode(rx_chan, &std_cfg);
    i2s_channel_enable(rx_chan);
}

Wake word com ESP-SR (AFE + WakeNet):

#include "esp_afe_sr_iface.h"
#include "esp_afe_sr_models.h"
#include "model_path.h"

static const esp_afe_sr_iface_t *afe = &ESP_AFE_SR_HANDLE;
static esp_afe_sr_data_t        *afe_data;

void wakenet_init(void) {
    srmodel_list_t *models = esp_srmodel_init("model");
    afe_config_t cfg = AFE_CONFIG_DEFAULT();
    cfg.wakenet_model_name = esp_srmodel_filter(
        models, ESP_WN_PREFIX, NULL);   // ex.: "wn9_hilexin"
    cfg.aec_init = false;
    cfg.pcm_config.total_ch_num = 1;
    cfg.pcm_config.mic_num      = 1;
    cfg.pcm_config.ref_num      = 0;
    cfg.pcm_config.sample_rate  = 16000;
    afe_data = afe->create_from_config(&cfg);
}

bool espera_wake_word(void) {
    int chunk = afe->get_feed_chunksize(afe_data);
    int16_t *buf = heap_caps_malloc(chunk * sizeof(int16_t),
                                    MALLOC_CAP_SPIRAM);
    while (1) {
        size_t n;
        i2s_channel_read(rx_chan, buf, chunk * sizeof(int16_t),
                         &n, portMAX_DELAY);
        afe->feed(afe_data, buf);
        afe_fetch_result_t *r = afe->fetch_with_delay(afe_data, 0);
        if (r && r->wakeup_state == WAKENET_DETECTED) return true;
    }
}

Conexão Wi-Fi (station):

#include "esp_wifi.h"
#include "esp_event.h"
#include "nvs_flash.h"

void wifi_init_sta(const char *ssid, const char *pass) {
    nvs_flash_init();
    esp_netif_init();
    esp_event_loop_create_default();
    esp_netif_create_default_wifi_sta();

    wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
    esp_wifi_init(&cfg);

    wifi_config_t wc = { 0 };
    strncpy((char *)wc.sta.ssid,     ssid, sizeof(wc.sta.ssid));
    strncpy((char *)wc.sta.password, pass, sizeof(wc.sta.password));

    esp_wifi_set_mode(WIFI_MODE_STA);
    esp_wifi_set_config(WIFI_IF_STA, &wc);
    esp_wifi_start();
    esp_wifi_connect();          // aguarde WIFI_EVENT_STA_GOT_IP
}

Chamada HTTP ao backend (LLM local ou nuvem):

#include "esp_http_client.h"

void pergunta_ao_llm(const char *json_body, char *saida, size_t max) {
    esp_http_client_config_t cfg = {
        .url        = "https://api.exemplo.com/v1/chat",
        .method     = HTTP_METHOD_POST,
        .timeout_ms = 8000,
    };
    esp_http_client_handle_t c = esp_http_client_init(&cfg);
    esp_http_client_set_header(c, "Content-Type",  "application/json");
    esp_http_client_set_header(c, "Authorization", "Bearer SUA_CHAVE");
    esp_http_client_set_post_field(c, json_body, strlen(json_body));
    esp_http_client_perform(c);
    int len = esp_http_client_read_response(c, saida, max - 1);
    if (len < 0) len = 0;
    saida[len] = '\0';
    esp_http_client_cleanup(c);
}

(e) Troubleshooting

  • PSRAM: o AFE e o WakeNet exigem memória contínua grande. Habilite PSRAM no menuconfig (CONFIG_SPIRAM) e aloque os buffers com heap_caps_malloc(…, MALLOC_CAP_SPIRAM). Sem PSRAM, o create_from_config falha ou o sistema reinicia por falta de memória.
  • Buffer I2S: estalos e cortes quase sempre vêm de DMA mal dimensionado. Ajuste dma_frame_num para múltiplos do chunksize do AFE (~15 ms) e aumente dma_desc_num se houver underrun. Confirme sample_rate = 16000 e slot de 32 bits (o INMP441 devolve dados alinhados à esquerda).
  • Falsos positivos: ganho alto demais é a causa número um. O AGC do AFE ajuda, mas calibre o nível de entrada, mantenha distância fixa do microfone e evite fontes de ruído contínuo perto do mic. Em ambiente barulhento, um modelo de wake word específico rende mais que aumentar o limiar.
  • Latência: separe a tarefa de áudio da tarefa de rede. Não bloqueie o loop do I2S esperando o HTTP; envie o comando para uma fila e deixe a resposta chegar por evento. Para respostas curtas, streaming por linhas JSON reduz a sensação de espera. Se a latência de nuvem incomodar, o backend local via Ollama elimina o RTT de internet.

(f) Aplicações reais

  • Companheiro de bancada que registra comandos de voz durante a montagem, sem tirar as mãos do protótipo.
  • Interface de laboratório: acionar roteiros de teste, iniciar gravação de logs ou consultar status por voz.
  • Nó de automação doméstica local-first, no qual o gatilho nunca sai do dispositivo.
  • Base de acessibilidade auditiva/motora, com retorno por display e áudio em uma única placa.
  • Plataforma de estudo de DSP embarcado: trocar modelos de wake word e medir falsos positivos e latência.

Para o panorama dos repositórios e das APIs, volte ao artigo anterior desta série. Para o contexto de voz offline, veja o material sobre assistentes de voz offline e a série J.A.R.V.I.S.; para o runtime de modelo, o texto sobre LLMs locais; e para a integração com hardware, a discussão de Edge AI. Os dois posts formam o par estratégia + montagem de um assistente de bancada.

Não some depois da matéria

Radar do hub: Núcleo Hits, Além do Oculto, Bobinho. Sem spray, sem lista comprada.

Radar do hub

Um e-mail quando sair matéria que importa — sem spray de newsletter genérica.