Comando de Voz com EchoVision

Placa usada neste tutorial Placa de desenvolvimento Z-S3 EchoVision Zion Automation
R$ 219,90 Ver produto

Seja bem-vindo de volta ao ecossistema Zion!

Nos últimos tutoriais, a sua EchoVision aprendeu a falar (amplificador NS4168) e a ouvir (microfones MEMS PDM). Hoje fechamos o ciclo com o passo mais satisfatório de todos: fazer a placa obedecer. Você vai falar “liga a luz” e ver um pino da placa acionar — primeiro o LED RGB, depois um relé de verdade.

E o melhor: sem pagar nenhuma API. Nada de mensalidade de nuvem. O reconhecimento de voz vai rodar num computador da sua própria rede — pode ser o seu PC, um Raspberry Pi ou aquele notebook velho encostado no armário — usando o Whisper, o modelo de transcrição open source da OpenAI, em português do Brasil.


O que você vai precisar:

  • Placa Zion EchoVision (ESP32-S3)
  • Cabo USB-C de dados
  • Arduino IDE configurada (core ESP32 instalado)
  • Um computador na mesma rede para ser o “cérebro”: PC com Linux, Raspberry Pi 4/5, ou notebook
  • Opcional (parte final): módulo de relé 5V com optoacoplador
  • Alto-falante: nenhum! Vamos usar o amplificador NS4168 que já vem na placa

Passo 1: Entendendo a Arquitetura (por que precisa de um servidor?)

Uma pergunta justa: “o ESP32-S3 não é potente? Por que ele não reconhece a voz sozinho?”

Ele até consegue reconhecer uma ou duas palavras fixas treinadas de fábrica (wake word). Mas entender frases livres em português — “liga”, “acende a luz”, “deixa vermelho” — exige um modelo de IA com centenas de milhões de parâmetros. Isso não cabe nos megabytes do microcontrolador. A solução clássica de engenharia é dividir o trabalho:

  1. A EchoVision (borda): escuta o ambiente o tempo todo, detecta quando alguém está falando, grava a frase e envia pela rede. Trabalho leve, em tempo real.
  2. O servidor (cérebro): recebe o áudio, roda o Whisper e devolve o texto transcrito. Trabalho pesado, mas sem pressa de microssegundos.
  3. A EchoVision de novo: recebe o texto, procura palavras-chave (“liga”, “desliga”, cores…) e aciona o pino correspondente.

O fluxo completo de cada comando é este:

Você fala → microfone PDM capta → placa detecta a fala (VAD) → grava até você silenciar → monta um arquivo WAV na memória → envia via HTTP POST para o servidor → Whisper transcreve → servidor responde um JSON com o texto → placa compara o texto com as palavras-chave → GPIO aciona.

Nota Técnica: Enquanto ninguém fala, nenhum áudio sai da placa. A detecção de atividade de voz (VAD) roda 100% localmente no ESP32-S3. Só depois que uma frase é detectada e gravada é que o pacote viaja pela rede — e apenas até o seu servidor, nunca para a internet. Privacidade por arquitetura.


Passo 2: Escolhendo o Servidor (PC, Raspberry Pi ou máquina dedicada?)

Qualquer computador na mesma rede da placa serve. O que muda é a velocidade da transcrição. Nossa referência honesta, medida com o modelo base do faster-whisper transcrevendo uma frase de ~2 segundos:

  • Seu PC do dia a dia (i5/Ryzen 5 ou melhor): transcrição em torno de 1 a 2 segundos. Perfeito para aprender e testar. Única desvantagem: o “assistente” desliga quando você desliga o PC.
  • Raspberry Pi 5: em torno de 3 a 6 segundos com o modelo tiny ou base. Ótimo custo-benefício para deixar ligado 24h consumindo poucos watts.
  • Raspberry Pi 4: funciona com o modelo tiny, esperando uns 5 a 10 segundos por frase. Dá para o projeto, mas a paciência é testada.
  • Máquina dedicada (mini PC, notebook aposentado, desktop i5 com 8-16 GB): o cenário ideal. Fica ligada direto, responde rápido e vira o “hub” de todos os seus projetos de voz. É exatamente assim que rodamos aqui na Zion.

O passo a passo abaixo assume Ubuntu/Debian (vale para o Raspberry Pi OS também). No Windows, o mesmo código Python funciona — só muda a forma de criar o serviço automático no final.


Passo 3: Instalando o Servidor de Voz (10 minutos)

Abra o terminal do computador escolhido e instale as dependências:

sudo apt update
sudo apt install -y python3 python3-pip python3-venv ffmpeg

# Cria uma pasta e um ambiente virtual pro projeto
mkdir -p ~/zion-voz && cd ~/zion-voz
python3 -m venv venv
source venv/bin/activate

# Instala o servidor web e o Whisper otimizado
pip install fastapi uvicorn faster-whisper

Nota Técnica: usamos o faster-whisper, uma reimplementação do Whisper com o motor CTranslate2. Mesma qualidade de transcrição, cerca de 4x mais rápido na CPU e usando menos memória. Na primeira execução ele baixa o modelo automaticamente (~150 MB para o base).

Agora crie o arquivo servidor.py dentro da pasta ~/zion-voz com o conteúdo abaixo. Ele é o cérebro inteiro do projeto — e cabe em 40 linhas:

"""
Zion Academy - Servidor de transcricao de voz
Recebe um WAV via POST /listen e devolve o texto em JSON.
"""
import io
import wave
import tempfile

from fastapi import FastAPI, Request
from faster_whisper import WhisperModel

app = FastAPI()

# Modelos disponiveis: tiny, base, small, medium, large-v3
# tiny  = mais rapido, menos preciso (use no Pi 4)
# base  = melhor equilibrio pra comandos curtos (recomendado)
# small = mais preciso, ~3x mais lento
print("Carregando o modelo Whisper... (a primeira vez baixa da internet)")
model = WhisperModel("base", device="cpu", compute_type="int8")
print("Modelo carregado. Servidor pronto!")


@app.post("/listen")
async def listen(request: Request):
    # 1. Recebe os bytes crus do WAV enviado pela placa
    audio_bytes = await request.body()

    # 2. Salva num arquivo temporario (o Whisper le arquivos)
    with tempfile.NamedTemporaryFile(suffix=".wav", delete=True) as f:
        f.write(audio_bytes)
        f.flush()

        # 3. Transcreve em portugues do Brasil
        segments, info = model.transcribe(f.name, language="pt")

        # 4. Junta os trechos num texto unico
        texto = " ".join(seg.text.strip() for seg in segments)

    print(f"Transcricao: '{texto}'")

    # 5. Devolve o JSON que a placa espera
    return {"transcricao": texto}

Entendendo o que cada parte faz:

  • WhisperModel("base", device="cpu", compute_type="int8") — carrega o modelo uma única vez, na inicialização. O int8 quantiza os pesos para inteiros de 8 bits: metade da memória, quase o dobro da velocidade, perda de precisão desprezível para comandos curtos.
  • @app.post("/listen") — cria o “endereço” que a placa vai chamar. Um endpoint HTTP é como um ramal telefônico: a placa liga para http://SEU_IP:8000/listen e entrega o áudio.
  • await request.body() — lê os bytes crus da requisição. A placa envia o WAV inteiro no corpo do POST, sem formulário, sem base64. Simples e eficiente.
  • language="pt" — trava o idioma em português. Sem isso o Whisper tenta adivinhar o idioma a cada frase e às vezes erra em comandos muito curtos.
  • return {"transcricao": texto} — o FastAPI converte o dicionário Python em JSON automaticamente. É esse campo transcricao que o firmware vai procurar.

Suba o servidor:

uvicorn servidor:app --host 0.0.0.0 --port 8000

Dica de Mestre: o --host 0.0.0.0 é obrigatório. Sem ele, o servidor só aceita conexões do próprio computador (localhost) e a placa nunca vai conseguir falar com ele. Esse é, disparado, o erro número 1 de quem monta esse tipo de projeto.

Teste sem a placa, do próprio computador (grava 3 segundos do seu microfone e envia):

arecord -f S16_LE -r 16000 -c 1 -d 3 teste.wav
curl -X POST --data-binary @teste.wav -H "Content-Type: audio/wav" http://localhost:8000/listen

Se voltar algo como {"transcricao":" Liga a luz."}, o cérebro do projeto está pronto. Metade do caminho andado!


Passo 4: Fixando o IP do Servidor (não pule!)

O firmware da placa vai guardar o endereço do servidor. Só que, por padrão, o roteador distribui IPs por DHCP — e pode trocar o endereço do seu computador a qualquer renovação. Quando isso acontece, a placa continua chamando o endereço antigo e tudo para de funcionar “do nada”. Falamos por experiência própria: esse fantasma já nos visitou.

Duas formas de resolver, escolha uma:

  1. Reserva no roteador (recomendada): entre na interface do roteador e procure por “Reserva de endereço”, “DHCP Binding” ou “Endereços IP estáticos” (nos ZTE fica em Rede local → LAN). Cadastre o MAC do seu servidor apontando para um IP fixo, ex.: 192.168.1.23. Detalhe importante: em muitos roteadores a reserva só é aceita se o IP estiver dentro da faixa do DHCP — confira o intervalo na mesma tela.
  2. IP estático no próprio Linux: configure via netplan (Ubuntu Server) ou nas configurações de rede (interface gráfica), definindo endereço manual, gateway e DNS. Vantagem: não depende do roteador. Cuidado: escolha um IP fora da faixa que o DHCP distribui, para ninguém receber o mesmo endereço.

Confirme com ip -4 addr show: o IP correto deve aparecer e, se estiver realmente estático, sem a palavra dynamic na linha. Anote esse endereço — ele entra no firmware já já.


Passo 5: O Firmware da EchoVision

Agora o lado da placa. Antes de colar o código, configure a Arduino IDE — dois itens aqui causam 90% dos problemas relatados:

  • Placa: ESP32S3 Dev Module
  • PSRAM: OPI PSRAM — sem isso a alocação do buffer de áudio falha silenciosamente
  • USB CDC On Boot: Enabled — sem isso o Monitor Serial fica mudo
  • Biblioteca: instale a ArduinoJson pelo Gerenciador de Bibliotecas (é a única dependência externa)

O sketch completo está no final do post para copiar de uma vez. Antes, vamos entender cada bloco — porque copiar sem entender é montar móvel sem olhar o manual: até funciona, mas sobra parafuso.

5.1 — Os pinos da EchoVision

#define MIC_PDM_WS    39          // clock do microfone PDM
#define MIC_PDM_SD    38          // dados do microfone PDM
#define MIC_I2S_PORT  I2S_NUM_0   // PDM RX so funciona na porta 0 do ESP32-S3

#define LED_PIN       2           // LED RGB WS2812 da placa
#define SAIDA_PIN     21          // pino livre que vai acionar o rele

Os microfones você já conhece do tutorial anterior: GPIO 39 é o clock, GPIO 38 os dados, modo PDM. A novidade é o GPIO 21 como saída de potência. Por que ele? Porque na EchoVision quase tudo está ocupado: a câmera usa os GPIOs 4 a 18, o áudio de saída usa 3, 45, 46 e 48, o LED usa o 2 e o botão BOOT o 0. O 21 está livre, não é pino de strapping (não interfere no boot) e não conflita com nenhum periférico da placa.

Nota Técnica: este sketch usa a API nova de I2S do ESP-IDF (driver/i2s_pdm.h), não a API antiga (driver/i2s.h) dos tutoriais mais antigos. O ESP-IDF não permite misturar as duas no mesmo firmware — e a API nova é o caminho para projetos que futuramente vão tocar áudio de resposta, como fazemos nos projetos avançados da Zion.

5.2 — O detector de fala (VAD): a placa sabe quando você está falando

int16_t calcularRMS(int16_t* buf, size_t n) {
  if (n == 0) return 0;

  // 1. Calcula a media do bloco (o offset DC do microfone)
  int64_t soma = 0;
  for (size_t i = 0; i < n; i++) soma += buf[i];
  int32_t media = (int32_t)(soma / (int64_t)n);

  // 2. Soma os quadrados das amostras JA SEM o offset
  int64_t somaQuadrados = 0;
  for (size_t i = 0; i < n; i++) {
    int32_t semDC = (int32_t)buf[i] - media;
    somaQuadrados += (int64_t)semDC * semDC;
  }

  // 3. RMS = raiz da media dos quadrados = "energia" do som
  return (int16_t) sqrt((double)somaQuadrados / n);
}

Esta funçãozinha é o coração da escuta. RMS (Root Mean Square) mede a energia de um sinal: silêncio dá RMS baixo, fala dá RMS alto. O loop principal calcula o RMS de cada bloco de áudio e, quando ele passa de um limiar, conclui: “tem alguém falando, bora gravar!”.

E o passo 1, de remover a média? É o pulo do gato. Microfones PDM entregam o sinal “deslocado”: em silêncio absoluto, as amostras não oscilam em torno de zero, mas em torno de um valor contínuo (offset DC). Se você calcular o RMS direto, esse offset entra na conta e o silêncio parece barulho — o limiar dispara sozinho o dia inteiro. Subtraindo a média do bloco antes, medimos só a variação do sinal, que é o que interessa.

5.3 — A gravação: começa na fala, termina no silêncio

if (calcularRMS(micChunk, amostrasLidas) > MIC_LIMIAR_RMS) {
  ultimaVozMs = millis();     // "ouvi voz agora"
  detectouFala = true;
}

// Se ja detectou fala E faz 700 ms que nao ouve mais nada: frase acabou
if (detectouFala && (millis() - ultimaVozMs > MIC_SILENCIO_MS)) break;

A lógica imita como nós conversamos: enquanto o RMS fica acima do limiar, a variável ultimaVozMs vai sendo atualizada. Quando você para de falar, ela congela — e se ficar congelada por 700 ms (MIC_SILENCIO_MS), o firmware entende que a frase terminou e encerra a gravação. Existe também um teto de segurança de 4 segundos, para a placa não gravar eternamente se o limiar estiver mal calibrado num ambiente barulhento.

5.4 — O cabeçalho WAV: 44 bytes que se apresentam

O microfone entrega amostras “nuas” — números de 16 bits, um atrás do outro. O Whisper precisa saber como interpretá-las: quantos canais? qual taxa de amostragem? quantos bits? Essa apresentação é o papel do cabeçalho WAV: 44 bytes no início do arquivo dizendo “sou PCM, mono, 16 kHz, 16 bits, e carrego N bytes de áudio”. A função construirCabecalhoWav() monta esses bytes na mão, campo por campo, seguindo o formato RIFF. Curiosidade de engenharia: é o mesmo formato de 1991 — coisa boa não envelhece.

Por que 16 kHz e não 44.100 Hz “qualidade de CD” como no tutorial do plotter? Porque o Whisper foi treinado com áudio de 16 kHz. Mandar mais que isso não melhora nada — só triplica o tamanho do upload e a latência.

5.5 — O envio HTTP: a placa vira “cliente de API”

HTTPClient http;
String url = String("http://") + SERVER_HOST + ":" + String(SERVER_PORT) + "/listen";
http.begin(url);
http.addHeader("Content-Type", "audio/wav");
http.setTimeout(20000);        // Whisper na CPU pode passar dos 5 s padrao
http.setConnectTimeout(5000);

int code = http.POST(payload, 44 + dataSize);   // 44 = cabecalho WAV

Aqui a EchoVision faz exatamente o que o seu navegador faz ao enviar um formulário: abre uma conexão TCP com o servidor, faz um POST com o WAV no corpo e espera a resposta. Dois timeouts merecem atenção: o setConnectTimeout(5000) é quanto ela espera para conseguir se conectar (se o IP estiver errado, o erro estoura aqui em 5 s), e o setTimeout(20000) é quanto ela espera pela resposta — folga necessária, porque o Whisper num Raspberry Pi pode levar vários segundos.

A resposta chega como JSON e a ArduinoJson faz a extração:

JsonDocument doc;
deserializeJson(doc, resp);
const char* transcricao = doc["transcricao"];   // o texto que voce falou!

5.6 — Texto vira ação: o if mais satisfatório da sua vida

String texto = String(transcricao);
texto.toLowerCase();

if (texto.indexOf("desliga") >= 0 || texto.indexOf("apaga") >= 0) {
  definirCor(0, 0, 0, "apagado");            // LED apaga, GPIO 21 em BAIXO
}
else if (texto.indexOf("vermelho") >= 0) {
  definirCor(BRILHO_COMANDO, 0, 0, "vermelho");
}
// ... verde, azul, amarelo, roxo ...
else if (texto.indexOf("liga") >= 0 || texto.indexOf("acende") >= 0) {
  definirCor(BRILHO_COMANDO, BRILHO_COMANDO, BRILHO_COMANDO, "branco");
}

O indexOf() procura uma palavra dentro do texto e devolve a posição (ou -1 se não achar). Simples assim: se a frase contém “vermelho”, fica vermelho. Mas repare na ordem dos testes, porque ela esconde duas armadilhas clássicas:

  1. “desligar” contém “ligar”. Se o teste de “liga” viesse primeiro, falar “desliga a luz” iria LIGAR o LED! Por isso o negativo é sempre testado antes do positivo.
  2. “liga o vermelho” contém “liga”. Se o comando genérico viesse antes das cores, essa frase acenderia branco em vez de vermelho. Por isso as cores específicas vêm antes do genérico.

Regra de ouro para gravar na parede do laboratório: do mais específico para o mais genérico. E aqui vai uma lição maior escondida: o firmware não “entende português” — ele faz busca de texto. Toda a inteligência linguística ficou no Whisper; a placa só compara strings. Saber onde mora cada responsabilidade é o que separa copiar projeto de projetar.

5.7 — Dois detalhes que evitam bugs fantasma

Limpeza do buffer do microfone: enquanto a requisição HTTP está em andamento (1 a 5 segundos), o DMA continua enchendo o buffer com áudio — inclusive com o eco da sua própria voz. Sem descartar esse áudio velho após a resposta, a placa “se re-escuta” e dispara de novo sozinha, entrando em loop. A função limparBufferMic() descarta 250 ms de leitura após cada requisição. Some a isso um COOLDOWN_MS de 1,5 s entre comandos e o sistema fica estável.

Estado seguro no boot: o pinMode(SAIDA_PIN, OUTPUT) e o desligamento da saída são as primeiras linhas do setup(), antes de Wi-Fi, antes de microfone, antes de tudo. Quando houver um relé controlando uma carga real, você não quer que ela ligue sozinha por um pino flutuando durante a inicialização.


Passo 6: Gravando e Calibrando

No topo do sketch, preencha as quatro linhas de configuração:

const char* WIFI_SSID   = "SUA_REDE_WIFI";
const char* WIFI_SENHA  = "SUA_SENHA_WIFI";
const char* SERVER_HOST = "192.168.1.23";   // o IP FIXO do Passo 4
const uint16_t SERVER_PORT = 8000;

Grave e abra o Monitor Serial em 115200. A sequência esperada:

  1. O LED faz uma varredura vermelho → verde → azul. É o autoteste: firmware rodando, LED sadio, Wi-Fi conectado.
  2. O Serial começa a imprimir [mic] rms=XX (limiar=600) a cada meio segundo.
  3. Fique em silêncio e observe o RMS. Ele deve ficar bem abaixo de 600. Se no silêncio já passar do limiar, aumente MIC_LIMIAR_RMS para o dobro do seu ruído de fundo. Se falar alto e não disparar, diminua.
  4. Fale “vermelho”. Assim que você silenciar, a placa emite um bipe agudo (“captei!”) e a história segue pelas cores: azul fraco (gravando) → amarelo fraco (Whisper trabalhando) → vermelho forte + dois bipes ascendentes (comando executado!).
  5. Fale “desliga”. Tudo apaga. Você acabou de comandar hardware por voz, com IA, sem pagar um centavo de API.

Dica de Mestre: deixe o log do servidor visível durante os testes (journalctl -f se rodar como serviço, ou o próprio terminal do uvicorn). Ver a transcrição aparecendo do lado do servidor enquanto o LED muda de cor na placa deixa todo o pipeline transparente — e é ouro para depurar: se o texto chega certo no servidor mas a ação não acontece, o problema está no if; se nem chega, está na rede.

Se algo travar (tabela de socorro)

  • Monitor Serial mudo → USB CDC On Boot está desabilitado na IDE.
  • “Sem memória pro buffer” → PSRAM não está em OPI PSRAM.
  • Erro HTTP -1 após ~5 segundos → a placa não alcançou o servidor: IP errado, servidor desligado, ou placa conectada em outra rede (confira o “IP da placa” no boot — precisa estar na mesma faixa do servidor).
  • Erro HTTP -1 imediato → servidor recusa a conexão: o uvicorn caiu ou está sem o --host 0.0.0.0.
  • Erro HTTP 404 → a URL não bate com o endpoint: confira se ambos usam /listen.
  • Dispara sozinho sem parar → limiar baixo demais para o ambiente, ou aumente o descarte do limparBufferMic() para 400 ms.
  • Transcrição vem errada ou vazia → fale a 30-50 cm da placa; em ambiente barulhento, suba o limiar para a placa só gravar fala firme.

Passo 7: Do LED para o Relé (o mundo real)

Aqui vem a melhor parte do projeto: não precisa mudar uma linha de código. O GPIO 21 já acompanha o estado desde o início — qualquer cor acesa coloca o pino em nível ALTO, “desliga” volta para BAIXO. O LED era o relé o tempo todo, só que em forma de luz.

Para acionar uma carga de verdade, use um módulo de relé 5V com optoacoplador (aqueles azuis clássicos):

  • IN do módulo → GPIO 21 da EchoVision
  • GND do módulo → GND da EchoVision (terra comum é obrigatório)
  • VCC do módulo → alimentação 5V (de preferência de uma fonte separada, não do regulador da placa)

Dois ajustes finos de quem já apanhou disso:

  1. Módulo ativo em nível baixo? Muitos módulos com optoacoplador ligam o relé quando IN vai a 0 V (lógica invertida). Se o seu ligar e desligar “ao contrário”, troque uma única linha no sketch: #define RELE_ATIVO_ALTO false.
  2. “Tec” fantasma no boot? Entre o energizar da placa e o pinMode() rodar, o GPIO fica flutuando por alguns milissegundos — o suficiente para alguns módulos darem um clique. Um resistor de pull-down de 10 kΩ entre IN e GND elimina o fantasma.

Atenção — segurança em primeiro lugar: os contatos do relé podem chavear cargas de rede elétrica (127/220 V), mas isso exige isolamento adequado, caixa fechada e experiência com instalações elétricas. Se está começando, fique nas cargas de baixa tensão: fita LED 12V, ventoinha, fechadura solenoide, sirene. O aprendizado é o mesmo e o risco é zero.


Passo 8: Feedback Sonoro (a placa confirma que entendeu)

Repare num detalhe de experiência do usuário: entre você terminar de falar e o LED mudar de cor existem alguns segundos de silêncio — o tempo do Whisper trabalhar. Nesse vácuo, a dúvida bate: “será que ela me ouviu?”. Assistentes comerciais resolvem isso com som: a Alexa faz “dlim” no instante em que capta sua voz, muito antes de responder. Não é enfeite — é mascaramento de latência. Você não elimina a espera; você a preenche com informação.

E a EchoVision tem um trunfo: o amplificador NS4168 (classe D, 3 W) já está na placa. Vamos usá-lo para criar uma linguagem de três sons:

  • 1 bipe agudo curto (Dó6, 120 ms) — “captei sua fala, estou processando”. Toca antes do áudio sair pela rede.
  • 2 bipes ascendentes (Dó→Mi) — “comando reconhecido e executado”.
  • 1 bipe grave (Sol4) — “ouvi, mas não reconheci nenhum comando”. Repare que isso diferencia dois problemas distintos na depuração: “não escutou” (nenhum bipe) de “não entendeu” (bipe grave).

E o melhor: sem nenhuma biblioteca de áudio. Em vez de tocar um arquivo, geramos a senoide matematicamente, amostra por amostra, e escrevemos direto no barramento I2S:

void tocarTom(float freqHz, int duracaoMs) {
  const uint32_t sr    = 16000;                     // taxa de amostragem
  const uint32_t total = sr * duracaoMs / 1000;     // quantas amostras gerar
  const uint32_t fade  = sr * 10 / 1000;            // 10 ms de fade in/out
  int16_t buf[256];
  uint32_t i = 0;

  i2s_channel_enable(spkTxHandle);
  while (i < total) {
    size_t n = 0;
    while (n < 256 && i < total) {
      float env = 1.0f;
      if (i < fade)              env = (float)i / fade;          // subindo
      else if (i > total - fade) env = (float)(total - i) / fade; // descendo
      buf[n++] = (int16_t)(sinf(2.0f * PI * freqHz * (float)i / sr)
                           * env * BIP_VOLUME * 32767.0f);
      i++;
    }
    size_t escrito = 0;
    i2s_channel_write(spkTxHandle, buf, n * sizeof(int16_t),
                      &escrito, portMAX_DELAY);
  }
  i2s_channel_disable(spkTxHandle);
}

Três decisões de engenharia escondidas nessas linhas:

  1. O envelope de fade (10 ms): uma senoide cortada seca não termina no zero — e essa descontinuidade vira um “TEC” audível no alto-falante. A rampa de subida e descida elimina o estalo. É o mesmo princípio de qualquer sintetizador.
  2. Canal habilitado só durante o tom: amplificador classe D em repouso com o barramento ativo gera um chiado de fundo. Habilitar/desabilitar a cada bipe deixa o silêncio realmente silencioso.
  3. Porta I2S_NUM_1: o alto-falante não pode dividir a porta 0 com o microfone — no ESP32-S3, o modo PDM RX só existe na porta 0 (limitação de silício). Por sorte o chip tem duas portas: microfone na 0, alto-falante na 1, cada um com seu clock.

Nota Técnica: o eco do próprio bipe entra pelo microfone da placa! Sem tratamento, ela “se escutaria” e dispararia de novo, em loop infinito. Duas defesas já embutidas no sketch: o limparBufferMic() descarta o áudio acumulado após cada ciclo, e o COOLDOWN_MS impõe 1,5 s de pausa entre comandos. Se no seu ambiente o bipe duplo ainda causar re-disparo, aumente o descarte de 250 para 400 ms.


Bônus: Servidor Sempre Ligado (systemd)

Rodar o uvicorn na mão é ótimo para testar, mas um assistente de voz precisa sobreviver a queda de energia. No Linux, transforme o servidor num serviço do sistema:

sudo tee /etc/systemd/system/zion-voz.service > /dev/null << 'EOF'
[Unit]
Description=Zion Academy - Servidor de Voz (Whisper)
After=network-online.target
Wants=network-online.target

[Service]
User=SEU_USUARIO
WorkingDirectory=/home/SEU_USUARIO/zion-voz
ExecStart=/home/SEU_USUARIO/zion-voz/venv/bin/uvicorn servidor:app --host 0.0.0.0 --port 8000
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now zion-voz.service

# Acompanhar o log ao vivo:
journalctl -u zion-voz.service -f

Troque SEU_USUARIO pelo seu usuário do Linux. Pronto: o servidor sobe sozinho com a máquina, reinicia se travar, e o journalctl -f mostra cada transcrição chegando em tempo real.


O Sketch Completo

Copie, preencha as 4 linhas de configuração e grave:

/*
  EchoVision - Comando de Voz com LED RGB + BIPES de feedback
  ===========================================================
  Teste de bancada. Nenhum hardware externo necessario.

  FEEDBACK SONORO (alto-falante NS4168 da propria placa):
    1 bipe agudo curto ....... "captei sua fala, processando"
    2 bipes ascendentes ...... "comando reconhecido e executado"
    1 bipe grave ............. "ouvi, mas nao reconheci o comando"

  O bipe de captacao e' o mais importante: ele toca ANTES do Whisper
  rodar, entao o usuario sabe na hora que foi ouvido - mesmo que a
  transcricao leve alguns segundos.

  COMANDOS RECONHECIDOS:
    "liga" / "acende"        -> LED branco
    "desliga" / "apaga"      -> LED apagado
    "vermelho"               -> LED vermelho
    "verde"                  -> LED verde
    "azul"                   -> LED azul
    "amarelo"                -> LED amarelo
    "roxo"                   -> LED roxo
    "branco"                 -> LED branco

  O GPIO 21 acompanha o estado em paralelo: qualquer cor = nivel ALTO,
  "desliga" = nivel BAIXO. Ou seja, quando voce plugar o modulo de rele
  neste pino, ele ja vai funcionar sem trocar uma linha de codigo.

  FEEDBACK VISUAL (o LED conta a historia inteira):
    varredura R-G-B no boot .. autoteste, firmware subiu e Wi-Fi conectou
    azul fraco ............... gravando sua fala
    amarelo fraco ............ enviando pro servidor / Whisper rodando
    cor forte ................ comando executado, estado atual
    apagado .................. em repouso, desligado

  O SERVIDOR NAO PRECISA DE NENHUMA ALTERACAO.
  Usa o mesmo /listen do Marco 5.4 e le so o campo "transcricao".

  BIBLIOTECA NECESSARIA: ArduinoJson (unica dependencia externa)

  CONFIGURACAO NA ARDUINO IDE:
    Placa .............. ESP32S3 Dev Module
    PSRAM .............. OPI PSRAM        <- obrigatorio
    USB CDC On Boot .... Enabled          <- pra ver o Serial Monitor
    Flash Size ......... 16MB (128Mb)
    Partition Scheme ... mantenha o mesmo que voce ja usa
*/

#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include "driver/i2s_pdm.h"
#include "driver/i2s_std.h"

// =========================================================================
// 1. CONFIGURE AQUI
// =========================================================================
const char* WIFI_SSID   = "SUA_REDE_WIFI";
const char* WIFI_SENHA  = "SUA_SENHA_WIFI";
const char* SERVER_HOST = "192.168.1.23";
const uint16_t SERVER_PORT = 8000;

// =========================================================================
// 2. PINOS DA PLACA Z-S3 ECHOVISION
// =========================================================================
#define MIC_PDM_WS    39
#define MIC_PDM_SD    38
#define MIC_I2S_PORT  I2S_NUM_0

#define LED_PIN       2      // LED RGB WS2812 da placa
#define SAIDA_PIN     21     // pino livre para o rele (etapa 2)

// Alto-falante (amplificador NS4168) - porta I2S_NUM_1
// (a porta 0 esta ocupada pelo microfone PDM - limitacao do ESP32-S3)
#define SPK_BCLK      48
#define SPK_LRC       45
#define SPK_DOUT       3
#define AMP_ENABLE    46
#define BIP_VOLUME    0.4f   // 0.0 a 1.0

#define RELE_ATIVO_ALTO  true   // troque pra false se o modulo for ativo em nivel baixo

// Brilho da COR COMANDADA (o "atuador"). 255 estoura na camera.
#define BRILHO_COMANDO  110
// Brilho dos avisos de estado. Fraco de proposito, pra nao confundir
// com o comando executado.
#define BRILHO_STATUS   25

// =========================================================================
// 3. PARAMETROS DE ESCUTA
// =========================================================================
#define MIC_SAMPLE_RATE      16000
#define MIC_LIMIAR_RMS       600     // calibre olhando o Serial Monitor
#define MIC_SILENCIO_MS      700
#define MIC_MAX_GRAVACAO_MS  4000
#define MIC_CHUNK_AMOSTRAS   256
#define COOLDOWN_MS          1500

#define DEBUG_RMS  true

// =========================================================================
// Globais
// =========================================================================
i2s_chan_handle_t micRxHandle = NULL;
i2s_chan_handle_t spkTxHandle = NULL;
int16_t micChunk[MIC_CHUNK_AMOSTRAS];
int16_t* gravBuf = nullptr;
const size_t GRAV_MAX_AMOSTRAS =
    (size_t)MIC_SAMPLE_RATE * (MIC_MAX_GRAVACAO_MS / 1000);

// cor comandada atualmente (o "estado" do atuador)
uint8_t corR = 0, corG = 0, corB = 0;
bool saidaLigada = false;

unsigned long ultimoComandoMs = 0;
unsigned long ultimoPrintDebug = 0;

// =========================================================================
// LED e saida
// =========================================================================
void mostrarStatus(uint8_t r, uint8_t g, uint8_t b) {
  rgbLedWrite(LED_PIN, r, g, b);
}

// Volta a exibir a cor que o usuario comandou
void mostrarEstadoAtual() {
  rgbLedWrite(LED_PIN, corR, corG, corB);
}

void aplicarSaida(bool ligar) {
  saidaLigada = ligar;
  bool nivel = RELE_ATIVO_ALTO ? ligar : !ligar;
  digitalWrite(SAIDA_PIN, nivel ? HIGH : LOW);
}

// Define a cor do LED E o estado do GPIO 21 de uma vez so
void definirCor(uint8_t r, uint8_t g, uint8_t b, const char* nome) {
  corR = r; corG = g; corB = b;
  mostrarEstadoAtual();
  aplicarSaida(!(r == 0 && g == 0 && b == 0));
  Serial.printf(">> LED: %s   |   GPIO%d: %s
",
                nome, SAIDA_PIN, saidaLigada ? "ALTO" : "BAIXO");
}

// Autoteste no boot: prova que o LED funciona antes de qualquer comando
void varreduraDeBoot() {
  rgbLedWrite(LED_PIN, BRILHO_COMANDO, 0, 0); delay(220);
  rgbLedWrite(LED_PIN, 0, BRILHO_COMANDO, 0); delay(220);
  rgbLedWrite(LED_PIN, 0, 0, BRILHO_COMANDO); delay(220);
  rgbLedWrite(LED_PIN, 0, 0, 0);
}

// =========================================================================
// Alto-falante (NS4168): bipes gerados na hora, sem biblioteca
// =========================================================================
void initAltoFalante() {
  pinMode(AMP_ENABLE, OUTPUT);
  digitalWrite(AMP_ENABLE, HIGH);   // habilita o amplificador classe D

  i2s_chan_config_t chanCfg =
      I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_1, I2S_ROLE_MASTER);
  i2s_new_channel(&chanCfg, &spkTxHandle, NULL);

  i2s_std_config_t stdCfg = {
    .clk_cfg  = I2S_STD_CLK_DEFAULT_CONFIG(16000),
    .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(
                    I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO),
    .gpio_cfg = {
      .mclk = I2S_GPIO_UNUSED,
      .bclk = (gpio_num_t)SPK_BCLK,
      .ws   = (gpio_num_t)SPK_LRC,
      .dout = (gpio_num_t)SPK_DOUT,
      .din  = I2S_GPIO_UNUSED,
      .invert_flags = { .mclk_inv = false, .bclk_inv = false,
                        .ws_inv = false },
    },
  };
  i2s_channel_init_std_mode(spkTxHandle, &stdCfg);
  // O canal fica desabilitado em repouso (sem chiado, sem consumo)
  Serial.println("Alto-falante OK (I2S std, porta 1)");
}

// Toca um tom senoidal com fade in/out de 10 ms (evita o "tec" de estalo)
void tocarTom(float freqHz, int duracaoMs) {
  if (!spkTxHandle) return;

  const uint32_t sr    = 16000;
  const uint32_t total = sr * (uint32_t)duracaoMs / 1000;
  const uint32_t fade  = sr * 10 / 1000;
  int16_t buf[256];
  uint32_t i = 0;

  i2s_channel_enable(spkTxHandle);
  while (i < total) {
    size_t n = 0;
    while (n < 256 && i < total) {
      float env = 1.0f;
      if (i < fade)                   env = (float)i / fade;
      else if (i > total - fade)      env = (float)(total - i) / fade;
      buf[n++] = (int16_t)(sinf(2.0f * PI * freqHz * (float)i / sr)
                           * env * BIP_VOLUME * 32767.0f);
      i++;
    }
    size_t escrito = 0;
    i2s_channel_write(spkTxHandle, buf, n * sizeof(int16_t),
                      &escrito, portMAX_DELAY);
  }
  i2s_channel_disable(spkTxHandle);
}

void bipCaptado()    { tocarTom(1047, 120); }                    // Do6
void bipSucesso()    { tocarTom(1047, 90); tocarTom(1319, 130); }// Do-Mi
void bipNaoEntendi() { tocarTom(392, 220); }                     // Sol4

// =========================================================================
// Wi-Fi
// =========================================================================
void conectarWiFi() {
  Serial.printf("Conectando em '%s'", WIFI_SSID);
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_SENHA);

  unsigned long inicio = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - inicio < 20000) {
    mostrarStatus(0, 0, BRILHO_STATUS); delay(150);
    mostrarStatus(0, 0, 0);             delay(150);
    Serial.print(".");
  }

  if (WiFi.status() == WL_CONNECTED) {
    Serial.print("
WiFi OK. IP da placa: ");
    Serial.println(WiFi.localIP());
  } else {
    Serial.println("
FALHOU. Confira SSID/senha e reinicie a placa.");
  }
}

// =========================================================================
// Microfone PDM
// =========================================================================
void initMic() {
  i2s_chan_config_t chanCfg =
      I2S_CHANNEL_DEFAULT_CONFIG(MIC_I2S_PORT, I2S_ROLE_MASTER);
  esp_err_t erroCanal = i2s_new_channel(&chanCfg, NULL, &micRxHandle);

  i2s_pdm_rx_config_t pdmCfg = {
    .clk_cfg  = I2S_PDM_RX_CLK_DEFAULT_CONFIG(MIC_SAMPLE_RATE),
    .slot_cfg = I2S_PDM_RX_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT,
                                               I2S_SLOT_MODE_MONO),
    .gpio_cfg = {
      .clk = (gpio_num_t)MIC_PDM_WS,
      .din = (gpio_num_t)MIC_PDM_SD,
      .invert_flags = { .clk_inv = false },
    },
  };

  esp_err_t erroInit   = i2s_channel_init_pdm_rx_mode(micRxHandle, &pdmCfg);
  esp_err_t erroEnable = i2s_channel_enable(micRxHandle);

  if (erroCanal != ESP_OK || erroInit != ESP_OK || erroEnable != ESP_OK) {
    Serial.printf("ERRO no microfone (canal=%d init=%d enable=%d)
",
                  erroCanal, erroInit, erroEnable);
  } else {
    Serial.println("Microfone OK (PDM RX)");
  }
}

// RMS com remocao do offset DC. Sem tirar o DC o microfone PDM entrega
// uma media alta e o limiar dispara sozinho no silencio.
int16_t calcularRMS(int16_t* buf, size_t n) {
  if (n == 0) return 0;

  int64_t soma = 0;
  for (size_t i = 0; i < n; i++) soma += buf[i];
  int32_t media = (int32_t)(soma / (int64_t)n);

  int64_t somaQuadrados = 0;
  for (size_t i = 0; i < n; i++) {
    int32_t semDC = (int32_t)buf[i] - media;
    somaQuadrados += (int64_t)semDC * semDC;
  }
  return (int16_t) sqrt((double)somaQuadrados / n);
}

// Depois de uma requisicao HTTP (1-3 s bloqueando), o buffer DMA esta
// cheio de audio velho. Sem descartar, a placa re-dispara sozinha na
// propria fala que acabou de processar.
void limparBufferMic() {
  size_t bytesLidos = 0;
  unsigned long t = millis();
  while (millis() - t < 250) {
    i2s_channel_read(micRxHandle, micChunk, sizeof(micChunk),
                     &bytesLidos, pdMS_TO_TICKS(10));
  }
}

// =========================================================================
// WAV
// =========================================================================
void construirCabecalhoWav(uint8_t* header, uint32_t dataSize,
                           uint32_t sampleRate) {
  uint32_t byteRate      = sampleRate * 2;
  uint32_t chunkSize     = 36 + dataSize;
  uint32_t subchunk1Size = 16;
  uint16_t audioFormat   = 1;
  uint16_t numChannels   = 1;
  uint16_t blockAlign    = 2;
  uint16_t bps           = 16;

  memcpy(header,      "RIFF", 4);
  memcpy(header + 4,  &chunkSize, 4);
  memcpy(header + 8,  "WAVE", 4);
  memcpy(header + 12, "fmt ", 4);
  memcpy(header + 16, &subchunk1Size, 4);
  memcpy(header + 20, &audioFormat, 2);
  memcpy(header + 22, &numChannels, 2);
  memcpy(header + 24, &sampleRate, 4);
  memcpy(header + 28, &byteRate, 4);
  memcpy(header + 32, &blockAlign, 2);
  memcpy(header + 34, &bps, 2);
  memcpy(header + 36, "data", 4);
  memcpy(header + 40, &dataSize, 4);
}

// =========================================================================
// O CORACAO DO PROJETO: texto -> acao
// =========================================================================
void executarComando(const char* transcricao) {
  String texto = String(transcricao);
  texto.toLowerCase();

  Serial.print("Texto reconhecido: \"");
  Serial.print(texto);
  Serial.println("\"");

  // A ORDEM DESTES IFs E' CRITICA. Duas armadilhas:
  //
  // 1) "desligar" CONTEM "ligar". Testar o positivo primeiro faz
  //    "desliga a luz" LIGAR o LED. Por isso o negativo vem antes.
  //
  // 2) "liga o vermelho" contem "liga". Se o generico vier antes das
  //    cores, essa frase acende branco em vez de vermelho. Por isso as
  //    cores especificas vem antes do generico.
  //
  // Regra pratica: do mais especifico para o mais generico.

  if (texto.indexOf("desliga")  >= 0 ||
      texto.indexOf("apaga")    >= 0 ||
      texto.indexOf("desativa") >= 0) {
    definirCor(0, 0, 0, "apagado");
  }
  else if (texto.indexOf("vermelho") >= 0) {
    definirCor(BRILHO_COMANDO, 0, 0, "vermelho");
  }
  else if (texto.indexOf("verde") >= 0) {
    definirCor(0, BRILHO_COMANDO, 0, "verde");
  }
  else if (texto.indexOf("azul") >= 0) {
    definirCor(0, 0, BRILHO_COMANDO, "azul");
  }
  else if (texto.indexOf("amarelo") >= 0) {
    definirCor(BRILHO_COMANDO, BRILHO_COMANDO, 0, "amarelo");
  }
  else if (texto.indexOf("roxo") >= 0) {
    definirCor(BRILHO_COMANDO, 0, BRILHO_COMANDO, "roxo");
  }
  else if (texto.indexOf("branco") >= 0 ||
           texto.indexOf("liga")   >= 0 ||
           texto.indexOf("acende") >= 0 ||
           texto.indexOf("ativa")  >= 0) {
    definirCor(BRILHO_COMANDO, BRILHO_COMANDO, BRILHO_COMANDO, "branco");
  }
  else {
    Serial.println(">> Nenhum comando reconhecido nessa frase.");
    bipNaoEntendi();
    ultimoComandoMs = millis();
    return;
  }

  bipSucesso();
  ultimoComandoMs = millis();
}

// =========================================================================
// Envia o WAV pro servidor e le a transcricao
// =========================================================================
void enviarAudioParaServidor(int16_t* amostras, size_t totalAmostras) {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("Sem Wi-Fi, nao da pra transcrever.");
    return;
  }

  uint32_t dataSize = (uint32_t)(totalAmostras * sizeof(int16_t));
  uint8_t wavHeader[44];
  construirCabecalhoWav(wavHeader, dataSize, MIC_SAMPLE_RATE);

  uint8_t* payload = (uint8_t*) ps_malloc(44 + dataSize);
  if (!payload) {
    Serial.println("Sem memoria pro WAV. Habilite a PSRAM na IDE!");
    return;
  }
  memcpy(payload, wavHeader, 44);
  memcpy(payload + 44, amostras, dataSize);

  HTTPClient http;
  String url = String("http://") + SERVER_HOST + ":" +
               String(SERVER_PORT) + "/listen";
  http.begin(url);
  http.addHeader("Content-Type", "audio/wav");
  http.setTimeout(20000);        // Whisper na CPU passa dos 5 s padrao
  http.setConnectTimeout(5000);

  unsigned long t0 = millis();
  int code = http.POST(payload, 44 + dataSize);
  Serial.printf("[LAT] /listen (upload + Whisper): %lu ms
", millis() - t0);
  free(payload);

  if (code != 200) {
    Serial.printf("Erro HTTP %d no /listen
", code);
    Serial.println(http.getString());
    http.end();
    return;
  }

  String resp = http.getString();
  http.end();

  JsonDocument doc;
  DeserializationError err = deserializeJson(doc, resp);
  if (err) {
    Serial.print("JSON invalido: ");
    Serial.println(err.c_str());
    Serial.println(resp);
    return;
  }

  const char* transcricao = doc["transcricao"];
  if (transcricao && strlen(transcricao) > 0) {
    executarComando(transcricao);
  } else {
    Serial.println("O servidor nao devolveu texto (silencio ou ruido).");
  }
}

// =========================================================================
// Grava enquanto tem fala, para no silencio
// =========================================================================
void escutarComando() {
  if (!gravBuf) {
    gravBuf = (int16_t*) ps_malloc(GRAV_MAX_AMOSTRAS * sizeof(int16_t));
    if (!gravBuf) {
      Serial.println("Sem memoria pro buffer. Habilite a PSRAM na IDE!");
      return;
    }
  }

  size_t totalGravado = 0;
  unsigned long ultimaVozMs = millis();
  unsigned long inicio = millis();
  bool detectouFala = false;

  Serial.println("
--- Ouvindo... ---");
  mostrarStatus(0, 0, BRILHO_STATUS);       // azul fraco = gravando

  while (millis() - inicio < MIC_MAX_GRAVACAO_MS) {
    size_t bytesLidos = 0;
    i2s_channel_read(micRxHandle, micChunk, sizeof(micChunk),
                     &bytesLidos, portMAX_DELAY);
    size_t amostrasLidas = bytesLidos / sizeof(int16_t);
    if (amostrasLidas == 0) continue;

    if (totalGravado + amostrasLidas <= GRAV_MAX_AMOSTRAS) {
      memcpy(gravBuf + totalGravado, micChunk,
             amostrasLidas * sizeof(int16_t));
      totalGravado += amostrasLidas;
    }

    if (calcularRMS(micChunk, amostrasLidas) > MIC_LIMIAR_RMS) {
      ultimaVozMs = millis();
      detectouFala = true;
    }

    if (detectouFala && (millis() - ultimaVozMs > MIC_SILENCIO_MS)) break;
  }

  Serial.printf("Gravado: %.1f s de audio
",
                totalGravado / (float)MIC_SAMPLE_RATE);

  // menos de 250 ms = provavelmente so um estalo, descarta
  if (totalGravado > MIC_SAMPLE_RATE / 4) {
    bipCaptado();          // feedback IMEDIATO: "captei, processando"
    mostrarStatus(BRILHO_STATUS, BRILHO_STATUS, 0);   // amarelo fraco
    enviarAudioParaServidor(gravBuf, totalGravado);
  }

  limparBufferMic();
  mostrarEstadoAtual();
}

// =========================================================================
// Setup / Loop
// =========================================================================
void setup() {
  Serial.begin(115200);
  delay(2000);
  Serial.println("
=== EchoVision - Comando de Voz -> LED RGB ===");

  pinMode(SAIDA_PIN, OUTPUT);
  aplicarSaida(false);          // estado seguro antes de tudo

  initMic();
  initAltoFalante();
  conectarWiFi();
  varreduraDeBoot();            // autoteste: R, G, B, apaga

  Serial.println("
>> Fale: liga / desliga / vermelho / verde / azul /");
  Serial.println(">>        amarelo / roxo / branco");
  Serial.printf(">> Calibre MIC_LIMIAR_RMS (%d) pelos valores abaixo.

",
                MIC_LIMIAR_RMS);
}

void loop() {
  size_t bytesLidos = 0;
  i2s_channel_read(micRxHandle, micChunk, sizeof(micChunk),
                   &bytesLidos, pdMS_TO_TICKS(10));
  if (bytesLidos == 0) return;

  size_t amostras = bytesLidos / sizeof(int16_t);
  int16_t rms = calcularRMS(micChunk, amostras);

  if (DEBUG_RMS && millis() - ultimoPrintDebug > 500) {
    Serial.printf("[mic] rms=%d (limiar=%d)
", rms, MIC_LIMIAR_RMS);
    ultimoPrintDebug = millis();
  }

  if (rms > MIC_LIMIAR_RMS && millis() - ultimoComandoMs > COOLDOWN_MS) {
    escutarComando();
  }
}

Desafio Zion

Você agora domina o pipeline completo: captura → VAD → WAV → HTTP → Whisper → ação. Com essa base, o céu é o limite. Que tal tentar:

  1. Palavra de ativação: modifique o executarComando() para só obedecer se a frase contiver “Zion” (ex.: “Zion, liga a luz”). Uma linha de indexOf() resolve — e evita que a TV comande a sua casa.
  2. Múltiplas saídas: adicione um segundo relé no GPIO 14 e crie comandos distintos (“liga a luz” / “liga o ventilador”). Dica: os GPIOs 14 e 47 também estão livres na EchoVision.
  3. Modo cena: crie o comando “modo cinema” que apaga o LED, aciona um relé e pisca uma confirmação — vários atuadores em um comando só.

Poste o resultado marcando a Zion nas redes — os melhores desafios aparecem no próximo vídeo do canal!

Compartilhe!

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *