A DFRobot ESP32-S3 AI Camera (DFR1154) é uma placa de desenvolvimento criada para projetos de IA e visão. Conta com um microcontrolador ESP32-S3 e integra um módulo de câmara, microfone e altifalante numa única placa.
Neste projeto, vamos criar um Vision Chatbot usando a placa ESP32-S3 AI Camera e a OpenAI. A placa captura imagens e escuta perguntas faladas através do microfone. Depois converte a fala em texto usando a OpenAI. Em seguida, envia tanto a imagem como a pergunta em texto para outro modelo da OpenAI para análise. Este modelo de IA processa a entrada, gera uma resposta sobre a imagem e devolve o texto da resposta. Finalmente, usamos a OpenAI uma terceira vez para converter o texto da resposta em fala e reproduzi-la pelo altifalante.
O pequeno vídeo abaixo mostra o Vision Chatbot em ação. Estou a segurar a placa à esquerda e a sua câmara aponta para um pequeno modelo de crânio na minha secretária. Pressiono o botão BOOT na placa para iniciar o Vision Chatbot e ouvirá a minha pergunta e a resposta do bot.
Ajuste o volume se não conseguir ouvir o áudio e note que há um atraso de alguns segundos entre a pergunta e a resposta devido à transferência de dados e processamento na OpenAI:
A gravação de áudio, reprodução e captura de imagem são feitas localmente pela placa ESP32-S3 AI Camera. Mas a análise de imagem, Text-to-Speech (TTS) e Speech-to-Text (STT) utilizam os modelos de IA da OpenAI. Portanto, é necessária uma ligação WiFi e uma chave API da OpenAI.
Peças Necessárias
Para este projeto uso o Módulo de Câmara AI ESP32-S3 (DFR1154) da DFRobot. Pode adquiri-lo na DFRobot através dos links abaixo. Certifique-se de obter a Versão 1.1 e não a versão mais antiga 1.0. Também pode precisar de um cabo USB-C.

DFRobot ESP32-S3 AI Camera

Cabo USB C
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.
Hardware da DFRobot AI Camera
A DFRobot ESP32-S3 AI Camera (DFR1154) é uma placa compacta de visão embutida e desenvolvimento de IA construída em torno do microcontrolador ESP32-S3 da Espressif. Integra conectividade sem fios, captura de imagem da câmara, entrada/saída de áudio e capacidades de aceleração de IA num único módulo. Esta placa é projetada para tarefas de computação de borda como reconhecimento de objetos, interação por voz e análise visual em tempo real.
A sua dimensão física é uma placa de circuito impresso quadrada com cerca de 42 mm de lado, o que a torna adequada para integração em robótica, sensores inteligentes e sistemas de monitorização IoT. A imagem abaixo mostra a frente e o verso da placa:

Microcontrolador e Arquitetura de Memória
No núcleo da placa, o microcontrolador ESP32-S3 executa o código da aplicação e gere as comunicações. Este microcontrolador possui uma CPU Tensilica Xtensa® dual-core 32-bit LX7 a até 240 MHz. Inclui SRAM embutida para acesso rápido a dados e instruções, bem como SRAM RTC dedicada para operações de relógio de baixo consumo.
A memória interna é complementada por armazenamento externo: a placa oferece 16 MB de memória flash para firmware e dados, e um chip PSRAM de 8 MB para suportar heaps maiores em tempo de execução necessários para buffering de imagem ou execução de modelos de IA. A interface USB é compatível com USB 2.0 OTG full-speed, permitindo alimentação e transferência de dados.
Subsistema da Câmara
O subsistema de imagem centra-se num sensor de câmara OmniVision OV3660. Este sensor captura imagens até 2 megapixels e inclui sensibilidade à luz visível e infravermelho de 940 nm, o que expande o alcance operacional para condições de pouca luz ou visão noturna.
A ótica oferece um campo de visão de aproximadamente 160 graus, e o sistema focal fixo tem uma distância focal de cerca de 0,95 mm com uma abertura próxima de f/2.0. Existem também quatro LEDs IR para iluminação.
Conectividade Sem Fios
A placa suporta comunicação sem fios em modo duplo. Para conectividade local, implementa Wi-Fi IEEE 802.11b/g/n na banda de 2.4 GHz, com suporte para canais de 20 MHz e 40 MHz e múltiplos modos operacionais incluindo estação, ponto de acesso soft e modos combinados estação+AP. O Bluetooth está disponível conforme os protocolos Bluetooth 5 e Bluetooth Mesh, permitindo comunicação peer de baixo consumo ou participação em redes de sensores.
Áudio e Interfaces de Sensores
Além da imagem, a placa tem um microfone I2S PDM embutido para captura de áudio, que é encaminhado através de um amplificador interno (MAX98357) e exposto numa interface dedicada para altifalante. Existe ainda uma porta para cartão SD que permite armazenar dados de áudio e vídeo.
O sensor de luz ambiente LTR-308 permite ajuste adaptativo da imagem ou escalonamento de energia com base na iluminação ambiental. Isto é especialmente útil em conjunto com os 4 LEDs IR presentes para iluminação.
A placa oferece uma interface Gravity de 4 pinos (3.3V, GND, GPIO44/RX, GPIO43/TX) que fornece conectividade UART/I2C simples a periféricos ou sensores externos. Note que na versão anterior V1.0 da placa o pino 1 da interface Gravity era uma entrada de 3.3-5V. Mas na versão atual V1.1 o pino 1 é uma saída de 3.3V!
Pinos GPIO
A tabela abaixo lista os pinos GPIO e a sua atribuição aos diferentes componentes de hardware como câmara (CAM), microfone (MIC), amplificador de áudio (MAX98357), sensor de luz ambiente (ALS), cartão SD (SD), LEDs IR, botão BOOT e LED de estado:

Gestão de Energia e Design Físico
A DFR1154 aceita múltiplas configurações de alimentação. Opera nominalmente a 3.3 V, podendo receber energia via conector USB-C a 5 V DC ou via conector VIN com uma gama mais ampla de 3.7 V a 15 V DC. Um CI dedicado de gestão de energia (HM6245) regula estas entradas para as tensões de núcleo necessárias.
A faixa de temperatura operacional especificada é aproximadamente de -10 °C até 60 °C, destinada a ambientes interiores padrão ou exteriores protegidos.
Especificações Técnicas
A tabela seguinte resume as principais especificações de hardware do módulo DFRobot ESP32-S3 AI Camera (DFR1154) (source):
| Especificação | Detalhes |
|---|---|
| Microcontrolador | ESP32-S3R8 com CPU Tensilica Xtensa LX7 dual-core, 240 MHz |
| SRAM | 512 KB |
| ROM | 384 KB |
| Flash Externa | 16 MB |
| PSRAM Externa | 8 MB |
| RTC SRAM | 16 KB |
| Sensor da Câmara | OV3660, 2 MP, 160° FoV, suporte a infravermelho |
| Ótica | Focal fixa de 0,95 mm, abertura f/2.0, distorção <8 % |
| Sem Fios | Wi-Fi 802.11b/g/n (2.4 GHz); Bluetooth 5 & Mesh |
| Áudio | Microfone I2S PDM; interface de altifalante via amplificador MAX98357 |
| Sensores | Sensor de luz ambiente LTR-308 |
| USB | USB 2.0 OTG Full Speed (Tipo-C) |
| Energia | Tensão de operação 3.3 V; USB-C 5 V; VIN 3.7–15 V |
| Botões | Reset e Boot |
| Dimensões | 42 mm × 42 mm |
| Temperatura de Operação | -10 °C a 60 °C |
Obter Chave API da OpenAI
O Vision Chatbot usa modelos de IA fornecidos pela OpenAI. Por isso, vai precisar de uma conta OpenAI. Vá a https://platform.openai.com e registe-se com um endereço de email ou uma conta Google ou Microsoft existente.
Após verificar o email e completar a configuração inicial, faça login no painel da OpenAI, platform.openai.com/api-keys e encontre ou crie a sua Chave API (=SECRET KEY) conforme mostrado abaixo:

A Chave API é uma cadeia única e longa, começando com “sk-proj-“, necessária para autenticar os seus pedidos API (veja abaixo). Mais tarde terá de copiar esta cadeia inteira para o código do Vision ChatBot.
sk-proj-xcA.......................OtDu0U
Isto é tudo o que realmente precisa, mas recomendo definir um limite de uso para a sua conta. Isso garante que não acabe com uma fatura cara devido a um bug no seu código (ex. enviar centenas de imagens).
Pode definir Limites de Uso e também consultar os Preços (Custos) dos diferentes modelos de IA na aba Billing (platform.openai.com/settings/organization/billing).

Eu próprio defini um Limite de Uso de 20 USD e não ativei a Recarga Automática. Esse orçamento pequeno dura bastante tempo, desde que use os modelos de IA mais baratos. Como pode ver, o meu saldo ainda é 14 USD e tenho experimentado os modelos OpenAI há vários meses.
Instalar Core ESP32
Se este for o seu primeiro projeto com uma placa da série ESP32, também precisará instalar o core ESP32. Se as placas ESP32 já estiverem instaladas no seu Arduino IDE, pode saltar esta secção.
Comece por abrir o diálogo Preferences selecionando “Preferences…” no menu “File”. Isto abrirá o diálogo Preferences mostrado abaixo.
Na aba Settings encontrará uma caixa de edição na parte inferior do diálogo rotulada “Additional boards manager URLs“:

Neste campo copie a seguinte URL:
https://espressif.github.io/arduino-esp32/package_esp32_dev_index.json
Isto permite ao Arduino IDE saber onde encontrar as bibliotecas core do ESP32. A seguir vamos instalar as placas ESP32 usando o Boards Manager.
Abra o Boards Manager via “Tools -> Boards -> Board Manager”. Verá o Boards Manager aparecer na barra lateral esquerda. Digite “ESP32” no campo de pesquisa no topo e deverá ver dois tipos de placas ESP32; as “Arduino ESP32 Boards” e as placas “esp32 by Espressif”. Queremos as “esp32 libraries by Espressif”. Clique no botão INSTALL e aguarde até a instalação terminar.

Estou a usar a versão atual 3.3.5 aqui, mas qualquer outra versão 3.x deve funcionar para este projeto.
Selecionar Placa
Também precisa de selecionar uma placa ESP32. No caso da DFRobot ESP32-S3 AI Camera, pode escolher o genérico “ESP32S3 Dev Module”. Para isso, clique no menu drop-down e depois em “Select other board and port…”:

Isto abrirá um diálogo onde pode digitar “esp32s3 dev” na barra de pesquisa. Verá a placa “ESP32S3 Dev Module” em Boards. Clique nela e no porto COM para ativá-la e depois clique OK:

Note que precisa de ligar a placa via cabo USB ao computador antes de poder selecionar uma porta COM.
Configurações da Ferramenta
Abaixo estão as configurações que deve usar com a placa. Encontra-as no menu Tools no seu Arduino IDE.

As configurações mais importantes são “16MB Flash Size”, “Huge APP partition” e “OPI PSRAM”. Para ver saída de texto no Serial Monitor, certifique-se que USB CDC on Boot está “Enabled”. As outras configurações são normalmente as predefinidas e estão bem assim.
Instalar Bibliotecas
O código para o Vision Chatbot usa duas bibliotecas, a ArduinoJson e a ESP32-audioI2S biblioteca. Abra o LIBRARY MANAGER, pesquise por “ArduinoJson” e “ESP32-audioI2S” e pressione o botão INSTALL para instalar estas bibliotecas:

Como pode ver, instalei a Versão 3.4.5 da biblioteca ESP32-audioI2S e a Versão 7.4.2 da biblioteca ArduinoJson. No entanto, a versão exata não deve importar muito.
Código para Vision Chatbot
Nesta secção mostro o código para o Vision Chatbot. É uma reimplementação do exemplo OpenAI image recognition que pode encontrar no DFRobot github repo mas que infelizmente não funciona de forma fiável. Por isso, reescrevi-o completamente.
O código começa por esperar que o botão BOOT seja pressionado e grava áudio até o botão BOOT ser libertado ou passarem mais de 3 segundos. O botão BOOT está no canto inferior direito no verso da placa, acima do botão reset (RST):

De seguida, o código envia o áudio gravado para o modelo de texto-para-fala da OpenAI para transcrição, captura uma imagem com a câmara e depois interroga o modelo GPT com capacidade de visão da OpenAI para responder a perguntas sobre a imagem. A resposta é convertida em áudio pelo modelo de fala-para-texto da OpenAI e reproduzida pelo pequeno altifalante do Módulo ESP32-S3 AI Camera.
Dê uma vista rápida ao código antes de discutirmos os detalhes. Se não quiser copiar e colar o código, pode descarregar o ficheiro vision_chatbot.zip que contém o código completo. Ainda terá de definir as credenciais WiFi e a Chave API da OpenAI no código, no entanto.
#include "WiFi.h"
#include "WiFiClientSecure.h"
#include "HTTPClient.h"
#include "ArduinoJson.h"
#include "esp_heap_caps.h"
#include "ESP_I2S.h"
#include "base64.h"
#include "camera.h"
#include "wav_header.h"
#include "Audio.h"
/* ===================== Pins ===================== */
#define BUTTON_PIN 0
#define LED_PIN 3
#define MIC_DATA_PIN 39
#define MIC_CLK_PIN 38
#define I2S_DOUT 42
#define I2S_BCLK 45
#define I2S_LRC 46
/* ===================== Audio ===================== */
#define SAMPLE_RATE 16000
#define MAX_RECORD_TIME_MS 3000
#define WAV_HEADER_SZ PCM_WAV_HEADER_SIZE
/* ===================== Models ===================== */
#define TTS_MODEL "tts-1"
#define TTS_VOICE "shimmer"
#define TTS_VOLUME 16
#define STT_MODEL "whisper-1"
#define VISION_MODEL "gpt-4o-mini"
/* ===================== Network ===================== */
const char* ssid = "ssid";
const char* password = "pwd";
const char* apiKey = "api-key";
/* ===================== Globals ===================== */
WiFiClientSecure secureClient;
I2SClass I2S;
Audio audio;
uint8_t* wavBuf = nullptr;
size_t wavSize = 0;
size_t wavMax = 0;
bool recording = false;
bool busy = false;
unsigned long recordStartMs = 0;
void initMic() {
I2S.setPinsPdmRx(MIC_CLK_PIN, MIC_DATA_PIN);
I2S.begin(I2S_MODE_PDM_RX, SAMPLE_RATE,
I2S_DATA_BIT_WIDTH_16BIT,
I2S_SLOT_MODE_MONO);
}
void startRecording() {
digitalWrite(LED_PIN, HIGH);
wavMax = WAV_HEADER_SZ + SAMPLE_RATE * 2 * MAX_RECORD_TIME_MS / 1000;
wavBuf = (uint8_t*)heap_caps_malloc(wavMax, MALLOC_CAP_SPIRAM);
Serial.printf("[REC] WAV buffer allocated in PSRAM (%u bytes)\n", wavMax);
if (!wavBuf) {
Serial.println("[ERR] WAV buffer allocation failed");
return;
}
pcm_wav_header_t hdr = PCM_WAV_HEADER_DEFAULT(0, 16, SAMPLE_RATE, 1);
memcpy(wavBuf, &hdr, WAV_HEADER_SZ);
wavSize = WAV_HEADER_SZ;
recordStartMs = millis();
recording = true;
Serial.println("[REC] Recording started");
}
void pollRecording() {
size_t avail = I2S.available();
if (!avail) return;
if (wavSize + avail > wavMax) {
stopRecording();
return;
}
wavSize += I2S.readBytes((char*)(wavBuf + wavSize), avail);
}
void stopRecording() {
recording = false;
if (!wavBuf || wavSize <= WAV_HEADER_SZ) {
Serial.println("[ERR] No audio recorded");
return;
}
pcm_wav_header_t* h = (pcm_wav_header_t*)wavBuf;
h->descriptor_chunk.chunk_size = wavSize - 8;
h->data_chunk.subchunk_size = wavSize - WAV_HEADER_SZ;
Serial.printf("[REC] Recording stopped, %u bytes total\n", wavSize);
digitalWrite(LED_PIN, LOW);
}
bool isValidWavBuffer() {
if (!wavBuf || wavSize < WAV_HEADER_SZ) {
Serial.println("[STT] Invalid WAV buffer");
return false;
}
return true;
}
void releaseWavBuffer() {
free(wavBuf);
wavBuf = nullptr;
wavSize = 0;
}
String buildMultipartHead(const char* boundary) {
return String("--") + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"model\"\r\n\r\n"
+ STT_MODEL + "\r\n--"
+ boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n"
+ "Content-Type: audio/wav\r\n\r\n";
}
String buildMultipartTail(const char* boundary) {
return "\r\n--" + String(boundary) + "--\r\n";
}
uint8_t* buildMultipartBody(
const String& head,
const String& tail,
size_t& outLen
) {
outLen = head.length() + wavSize + tail.length();
uint8_t* body =
(uint8_t*)heap_caps_malloc(outLen, MALLOC_CAP_SPIRAM);
if (!body) {
Serial.println("[STT] Out of memory (multipart)");
return nullptr;
}
memcpy(body, head.c_str(), head.length());
memcpy(body + head.length(), wavBuf, wavSize);
memcpy(body + head.length() + wavSize, tail.c_str(), tail.length());
Serial.printf("[STT] Multipart body in PSRAM (%u bytes)\n", outLen);
return body;
}
int postMultipart(
uint8_t* body,
size_t bodyLen,
const char* boundary,
String& response
) {
HTTPClient http;
http.begin(secureClient,
"https://api.openai.com/v1/audio/transcriptions");
http.addHeader("Authorization", String("Bearer ") + apiKey);
http.addHeader("Content-Type",
String("multipart/form-data; boundary=") + boundary);
Serial.printf("[STT] Uploading %u bytes\n", bodyLen);
int code = http.POST(body, bodyLen);
Serial.printf("[STT] HTTP code: %d\n", code);
if (code == 200) {
response = http.getString();
} else {
Serial.println("[STT] Error response:");
Serial.println(http.getString());
}
http.end();
return code;
}
String parseSttResponse(const String& resp) {
StaticJsonDocument<512> doc;
if (deserializeJson(doc, resp)) {
Serial.println("[STT] JSON parse failed");
return "";
}
return doc["text"] | "";
}
String speechToText() {
Serial.println("[STT] Building multipart body");
Serial.printf(
"[HEAP] Internal heap free before TLS: %u\n",
heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
if (!isValidWavBuffer()) {
return "";
}
const char* boundary = "----ESP32Boundary";
String head = buildMultipartHead(boundary);
String tail = buildMultipartTail(boundary);
size_t bodyLen = 0;
uint8_t* body = buildMultipartBody(head, tail, bodyLen);
if (!body) {
return "";
}
releaseWavBuffer();
String response;
int code = postMultipart(body, bodyLen, boundary, response);
free(body);
if (code != 200) {
return "";
}
return parseSttResponse(response);
}
void textToSpeech(String text) {
busy = true;
audio.openai_speech(apiKey, TTS_MODEL,
text.c_str(), "",
TTS_VOICE, "mp3", "1");
}
String visionAnswer(String question, camera_fb_t* fb) {
Serial.println("[VISION] Encoding image...");
String imageBase64 = base64::encode(fb->buf, fb->len);
HTTPClient http;
http.begin(secureClient, "https://api.openai.com/v1/chat/completions");
http.addHeader("Authorization", String("Bearer ") + apiKey);
http.addHeader("Content-Type", "application/json");
StaticJsonDocument<3072> req;
req["model"] = "gpt-4o-mini";
JsonArray msgs = req.createNestedArray("messages");
JsonObject system = msgs.createNestedObject();
system["role"] = "system";
system["content"] =
"You are a helpful vision assistant. Analyze images and answer questions concisely";
JsonObject user = msgs.createNestedObject();
user["role"] = "user";
JsonArray content = user.createNestedArray("content");
JsonObject txt = content.createNestedObject();
txt["type"] = "text";
txt["text"] = question;
JsonObject img = content.createNestedObject();
img["type"] = "image_url";
img["image_url"]["url"] =
"data:image/jpeg;base64," + imageBase64;
String body;
serializeJson(req, body);
Serial.println("[VISION] Sending request...");
int code = http.POST(body);
esp_camera_fb_return(fb);
Serial.printf("[VISION] HTTP code: %d\n", code);
if (code <= 0) {
http.end();
return "";
}
String payload = http.getString();
http.end();
// Serial.println("[VISION] Response:");
// Serial.println(payload);
StaticJsonDocument<1024> resp;
if (deserializeJson(resp, payload)) {
Serial.println("[ERR] Vision JSON parse failed");
return "";
}
return resp["choices"][0]["message"]["content"] | "";
}
camera_fb_t* captureImage() {
return esp_camera_fb_get();
}
void initSpeaker() {
audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT);
audio.setVolume(TTS_VOLUME);
}
void initPins() {
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
}
void initTime() {
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
time_t now;
while (time(&now) < 100000) delay(100);
}
void initWiFi() {
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) delay(200);
}
void initSerial() {
Serial.begin(115200);
delay(100);
}
void setup() {
secureClient.setInsecure();
initSerial();
initPins();
initWiFi();
initTime();
initMic();
initSpeaker();
initCamera();
Serial.println("Ready");
}
void loop() {
audio.loop();
if (busy && !audio.isRunning()) {
busy = false;
}
if (busy) return;
static bool lastBtn = HIGH;
bool btn = digitalRead(BUTTON_PIN);
if (btn == LOW && lastBtn == HIGH && !recording) {
startRecording();
}
if (recording) {
pollRecording();
bool released = (btn == HIGH && lastBtn == LOW);
bool timeout = (millis() - recordStartMs >= MAX_RECORD_TIME_MS);
if (released || timeout) {
stopRecording();
String q = speechToText();
if (q.length()) {
Serial.printf("[STT] %s\n", q.c_str());
camera_fb_t* fb = captureImage();
if (fb) {
String a = visionAnswer(q, fb);
Serial.printf("[VISION] %s\n", a.c_str());
if (a.length()) {
textToSpeech(a);
}
}
}
}
}
lastBtn = btn;
delay(5);
}
Importações
O código começa por incluir as bibliotecas necessárias para conectividade WiFi, comunicação HTTP segura, análise JSON, controlo da câmara, processamento de áudio e codificação base64. Estas bibliotecas permitem que o ESP32-S3 interaja com periféricos de hardware e comunique com as APIs cloud da OpenAI.
#include "WiFi.h" #include "WiFiClientSecure.h" #include "HTTPClient.h" #include "ArduinoJson.h" #include "esp_heap_caps.h" #include "ESP_I2S.h" #include "base64.h" #include "camera.h" #include "wav_header.h" #include "Audio.h"
Pinos e Configuração de Áudio
Várias constantes definem os pinos GPIO usados para o botão, LED, dados e relógio do microfone, e pinos de saída de áudio I2S. Parâmetros de áudio como taxa de amostragem, tempo máximo de gravação e tamanho do cabeçalho WAV também são definidos para configurar a captura e reprodução de áudio.
#define BUTTON_PIN 0 #define LED_PIN 3 #define MIC_DATA_PIN 39 #define MIC_CLK_PIN 38 #define I2S_DOUT 42 #define I2S_BCLK 45 #define I2S_LRC 46 #define SAMPLE_RATE 16000 #define MAX_RECORD_TIME_MS 3000 #define WAV_HEADER_SZ PCM_WAV_HEADER_SIZE
Modelos e Credenciais de Rede
O código especifica os modelos OpenAI usados para texto-para-fala (TTS), fala-para-texto (STT) e resposta a perguntas visuais. Também armazena o SSID WiFi, palavra-passe e chave API da OpenAI como constantes para conexão de rede e autenticação API.
#define TTS_MODEL "tts-1" #define TTS_VOICE "shimmer" #define TTS_VOLUME 16 #define STT_MODEL "whisper-1" #define VISION_MODEL "gpt-4o-mini" const char* ssid = "ssid"; const char* password = "pwd"; const char* apiKey = "apikey";
Deve substituir os valores fictícios para ssid, password e apiKey da OpenAI pelos valores corretos da sua rede WiFi e da sua chave API da OpenAI. Caso contrário, o Vision Chatbot não poderá comunicar com os modelos de IA da OpenAI e não funcionará.
Variáveis Globais e Objetos
O código declara um cliente WiFi seguro para comunicação HTTPS, um objeto de interface de áudio I2S e um objeto Audio para reprodução. Também gere um buffer para armazenar dados de áudio WAV gravados, flags para estados de gravação e ocupado, e variáveis de tempo para controlar a duração da gravação.
WiFiClientSecure secureClient; I2SClass I2S; Audio audio; uint8_t* wavBuf = nullptr; size_t wavSize = 0; size_t wavMax = 0; bool recording = false; bool busy = false; unsigned long recordStartMs = 0;
Inicialização do Microfone
A função initMic() configura o periférico I2S para receber dados do microfone PDM usando os pinos de relógio e dados especificados. Define a taxa de amostragem, largura de bits dos dados e modo mono para preparar a gravação de áudio.
void initMic() {
I2S.setPinsPdmRx(MIC_CLK_PIN, MIC_DATA_PIN);
I2S.begin(I2S_MODE_PDM_RX, SAMPLE_RATE,
I2S_DATA_BIT_WIDTH_16BIT,
I2S_SLOT_MODE_MONO);
}
Controlo da Gravação de Áudio
A função startRecording() inicia a captura de áudio ligando o LED indicador e alocando um buffer na PSRAM para conter os dados WAV. Escreve um cabeçalho WAV padrão no buffer, regista o tempo de início e define a flag de gravação.
void startRecording() {
digitalWrite(LED_PIN, HIGH);
wavMax = WAV_HEADER_SZ + SAMPLE_RATE * 2 * MAX_RECORD_TIME_MS / 1000;
wavBuf = (uint8_t*)heap_caps_malloc(wavMax, MALLOC_CAP_SPIRAM);
Serial.printf("[REC] WAV buffer allocated in PSRAM (%u bytes)\n", wavMax);
if (!wavBuf) {
Serial.println("[ERR] WAV buffer allocation failed");
return;
}
pcm_wav_header_t hdr = PCM_WAV_HEADER_DEFAULT(0, 16, SAMPLE_RATE, 1);
memcpy(wavBuf, &hdr, WAV_HEADER_SZ);
wavSize = WAV_HEADER_SZ;
recordStartMs = millis();
recording = true;
Serial.println("[REC] Recording started");
}
A função pollRecording() lê os dados de áudio disponíveis do periférico I2S e adiciona-os ao buffer WAV. Se o buffer ficar cheio, para a gravação automaticamente.
void pollRecording() {
size_t avail = I2S.available();
if (!avail) return;
if (wavSize + avail > wavMax) {
stopRecording();
return;
}
wavSize += I2S.readBytes((char*)(wavBuf + wavSize), avail);
}
A função stopRecording() finaliza os dados WAV atualizando o cabeçalho com os tamanhos corretos, desativa a flag de gravação e apaga o LED indicador.
void stopRecording() {
recording = false;
if (!wavBuf || wavSize <= WAV_HEADER_SZ) {
Serial.println("[ERR] No audio recorded");
return;
}
pcm_wav_header_t* h = (pcm_wav_header_t*)wavBuf;
h->descriptor_chunk.chunk_size = wavSize - 8;
h->data_chunk.subchunk_size = wavSize - WAV_HEADER_SZ;
Serial.printf("[REC] Recording stopped, %u bytes total\n", wavSize);
digitalWrite(LED_PIN, LOW);
}
Construção do Pedido Multipart para Speech-to-Text
Para enviar o áudio gravado ao modelo Speech-to-Text da OpenAI, o código constrói um corpo de pedido HTTP multipart/form-data. As funções buildMultipartHead() e buildMultipartTail() criam as fronteiras e cabeçalhos multipart, enquanto buildMultipartBody() monta o corpo completo do pedido na PSRAM concatenando a cabeça, os dados WAV e a cauda.
String buildMultipartHead(const char* boundary) {
return String("--") + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"model\"\r\n\r\n"
+ STT_MODEL + "\r\n--"
+ boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n"
+ "Content-Type: audio/wav\r\n\r\n";
}
String buildMultipartTail(const char* boundary) {
return "\r\n--" + String(boundary) + "--\r\n";
}
uint8_t* buildMultipartBody(
const String& head,
const String& tail,
size_t& outLen
) {
outLen = head.length() + wavSize + tail.length();
uint8_t* body =
(uint8_t*)heap_caps_malloc(outLen, MALLOC_CAP_SPIRAM);
if (!body) {
Serial.println("[STT] Out of memory (multipart)");
return nullptr;
}
memcpy(body, head.c_str(), head.length());
memcpy(body + head.length(), wavBuf, wavSize);
memcpy(body + head.length() + wavSize, tail.c_str(), tail.length());
Serial.printf("[STT] Multipart body in PSRAM (%u bytes)\n", outLen);
return body;
}
A constante STT_MODEL especifica o modelo Speech-to-Text usado. Estou a usar “whisper-1” aqui, mas a OpenAI tem outros Speech-to-Text modelos como “gpt-4o-mini-transcribe”, “gpt-4o-transcribe” ou “gpt-4o-transcribe-diarize” que pode experimentar.
HTTP POST para Speech-to-Text
A função postMultipart() executa o pedido HTTPS POST para o endpoint de transcrição de áudio da OpenAI. Define os cabeçalhos de autorização e content-type, envia o corpo multipart e obtém a resposta. A função retorna o código de estado HTTP e armazena a resposta em string.
int postMultipart(
uint8_t* body,
size_t bodyLen,
const char* boundary,
String& response
) {
HTTPClient http;
http.begin(secureClient,
"https://api.openai.com/v1/audio/transcriptions");
http.addHeader("Authorization", String("Bearer ") + apiKey);
http.addHeader("Content-Type",
String("multipart/form-data; boundary=") + boundary);
Serial.printf("[STT] Uploading %u bytes\n", bodyLen);
int code = http.POST(body, bodyLen);
Serial.printf("[STT] HTTP code: %d\n", code);
if (code == 200) {
response = http.getString();
} else {
Serial.println("[STT] Error response:");
Serial.println(http.getString());
}
http.end();
return code;
}
Processamento Speech-to-Text
A função speechToText() orquestra o processo de construir o pedido multipart, enviá-lo e analisar a resposta JSON para extrair o texto transcrito. Também gere a memória libertando o buffer WAV após o envio.
String speechToText() {
Serial.println("[STT] Building multipart body");
Serial.printf(
"[HEAP] Internal heap free before TLS: %u\n",
heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
if (!isValidWavBuffer()) {
return "";
}
const char* boundary = "----ESP32Boundary";
String head = buildMultipartHead(boundary);
String tail = buildMultipartTail(boundary);
size_t bodyLen = 0;
uint8_t* body = buildMultipartBody(head, tail, bodyLen);
if (!body) {
return "";
}
releaseWavBuffer();
String response;
int code = postMultipart(body, bodyLen, boundary, response);
free(body);
if (code != 200) {
return "";
}
return parseSttResponse(response);
}
Resposta a Perguntas Visuais
A função visionAnswer() envia uma pergunta juntamente com uma imagem capturada para o modelo GPT-4o-mini da OpenAI para resposta a perguntas baseadas em visão. Codifica a imagem em base64, constrói um pedido JSON de chat com mensagens do sistema e do utilizador, e analisa a resposta para extrair a resposta do assistente.
String visionAnswer(String question, camera_fb_t* fb) {
Serial.println("[VISION] Encoding image...");
String imageBase64 = base64::encode(fb->buf, fb->len);
HTTPClient http;
http.begin(secureClient, "https://api.openai.com/v1/chat/completions");
http.addHeader("Authorization", String("Bearer ") + apiKey);
http.addHeader("Content-Type", "application/json");
StaticJsonDocument<3072> req;
req["model"] = VISION_MODEL;
JsonArray msgs = req.createNestedArray("messages");
JsonObject system = msgs.createNestedObject();
system["role"] = "system";
system["content"] =
"You are a helpful vision assistant. Analyze images and answer questions concisely";
JsonObject user = msgs.createNestedObject();
user["role"] = "user";
JsonArray content = user.createNestedArray("content");
JsonObject txt = content.createNestedObject();
txt["type"] = "text";
txt["text"] = question;
JsonObject img = content.createNestedObject();
img["type"] = "image_url";
img["image_url"]["url"] =
"data:image/jpeg;base64," + imageBase64;
String body;
serializeJson(req, body);
Serial.println("[VISION] Sending request...");
int code = http.POST(body);
esp_camera_fb_return(fb);
Serial.printf("[VISION] HTTP code: %d\n", code);
if (code <= 0) {
http.end();
return "";
}
String payload = http.getString();
http.end();
StaticJsonDocument<1024> resp;
if (deserializeJson(resp, payload)) {
Serial.println("[ERR] Vision JSON parse failed");
return "";
}
return resp["choices"][0]["message"]["content"] | "";
}
A constante VISION_MODEL especifica o OpenAI vision model usado. Estou a usar “gpt-4o-mini” mas há outros como “gpt-image-1”, “gpt-5-mini”, “gpt-5-nano” ou “gpt-4.1-nano” que pode experimentar. Têm diferentes capacidades, velocidades e custos.
Reprodução Text-to-Speech
A função textToSpeech() usa a biblioteca ESP32-audioI2S para pedir síntese de fala ao modelo TTS da OpenAI. Define a flag ocupado para evitar operações sobrepostas enquanto o áudio está a ser reproduzido.
void textToSpeech(String text) {
busy = true;
audio.openai_speech(apiKey, TTS_MODEL,
text.c_str(), "",
TTS_VOICE, "mp3", "1");
}
A constante TTS_MODEL especifica o modelo Text-to-Speech usado. Estou a usar “tts-1” mas também pode usar “tts-1-hd”. O modelo “tts-1” oferece menor latência, mas qualidade inferior ao “tts-1-hd”. Um modelo mais inteligente, mas também mais caro, é o “gpt-4o-mini-tts” que também pode usar.
A voz para a saída de áudio é especificada pela constante TTS_VOICE, que está definida para “shimmer”. Pode experimentar outras vozes, mas note que a disponibilidade depende do modelo. Os modelos tts-1 e tts-1-hd suportam um conjunto menor de vozes: “alloy”, “ash”, “coral”, “echo”, “fable”, “onyx”, “nova”, “sage” e “shimmer” (platform.openai.com/docs/guides/text-to-speech).
Captura da Câmara
A função captureImage() captura um frame da câmara ESP32 e devolve um ponteiro para o buffer do frame para processamento posterior.
camera_fb_t* captureImage() {
return esp_camera_fb_get();
}
Inicialização do Hardware
Várias funções auxiliares inicializam componentes de hardware e serviços do sistema. initSpeaker() configura os pinos de saída de áudio e volume. initPins() configura os modos GPIO do botão e LED. initTime() sincroniza a hora do sistema usando servidores NTP. initWiFi() conecta-se à rede WiFi especificada. initSerial() inicia a comunicação serial para depuração.
void initSpeaker() {
audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT);
audio.setVolume(TTS_VOLUME);
}
void initPins() {
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
}
void initTime() {
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
time_t now;
while (time(&now) < 100000) delay(100);
}
void initWiFi() {
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) delay(200);
}
void initSerial() {
Serial.begin(115200);
delay(100);
}
Função Setup
A função setup() é chamada uma vez na inicialização. Configura o cliente seguro para ignorar verificação de certificado, inicializa comunicação serial, pinos GPIO, WiFi, hora do sistema, microfone, altifalante e câmara. Finalmente, imprime “Ready” para indicar que o sistema está preparado.
void setup() {
secureClient.setInsecure();
initSerial();
initPins();
initWiFi();
initTime();
initMic();
initSpeaker();
initCamera();
Serial.println("Ready");
}
Função Loop
A função loop() executa-se repetidamente. Processa a reprodução de áudio em segundo plano. Se o sistema estiver ocupado a reproduzir áudio, espera. Caso contrário, lê o estado do botão para detectar pressões e libertações.
Quando o botão é pressionado, inicia a gravação. Durante a gravação, os dados de áudio são lidos e adicionados ao buffer. A gravação para quando o botão é libertado ou o tempo máximo de gravação é atingido.
Após parar, o áudio gravado é enviado para transcrição. Se a transcrição for bem-sucedida, a câmara captura uma imagem e a pergunta é enviada ao modelo de visão. A resposta é então convertida em fala e reproduzida. O loop inclui um pequeno atraso para debouncing do botão.
void loop() {
audio.loop();
if (busy && !audio.isRunning()) {
busy = false;
}
if (busy) return;
static bool lastBtn = HIGH;
bool btn = digitalRead(BUTTON_PIN);
if (btn == LOW && lastBtn == HIGH && !recording) {
startRecording();
}
if (recording) {
pollRecording();
bool released = (btn == HIGH && lastBtn == LOW);
bool timeout = (millis() - recordStartMs >= MAX_RECORD_TIME_MS);
if (released || timeout) {
stopRecording();
String q = speechToText();
if (q.length()) {
Serial.printf("[STT] %s\n", q.c_str());
camera_fb_t* fb = captureImage();
if (fb) {
String a = visionAnswer(q, fb);
Serial.printf("[VISION] %s\n", a.c_str());
if (a.length()) {
textToSpeech(a);
}
}
}
}
}
lastBtn = btn;
delay(5);
}
E este é o código completo para o Vision Chatbot em si. No entanto, também precisamos de algum código para a câmara, que é descrito na próxima secção.
Código Camera.h
O código da câmara é essencialmente uma cópia do ficheiro “camera.h” do exemplo OpenAI image recognition que pode encontrar no DFRobot github repo.
#include "esp_camera.h"
#include "soc/soc.h" // Disable brownout problems
#include "soc/rtc_cntl_reg.h" // Disable brownout problems
#include <Arduino.h>
// OV2640 camera module pins (CAMERA_MODEL_AI_THINKER)
#define PWDN_GPIO_NUM -1
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 5
#define SIOD_GPIO_NUM 8
#define SIOC_GPIO_NUM 9
#define Y9_GPIO_NUM 4
#define Y8_GPIO_NUM 6
#define Y7_GPIO_NUM 7
#define Y6_GPIO_NUM 14
#define Y5_GPIO_NUM 17
#define Y4_GPIO_NUM 21
#define Y3_GPIO_NUM 18
#define Y2_GPIO_NUM 16
#define VSYNC_GPIO_NUM 1
#define HREF_GPIO_NUM 2
#define PCLK_GPIO_NUM 15
void initCamera() {
camera_config_t config;
config.ledc_channel = LEDC_CHANNEL_0;
config.ledc_timer = LEDC_TIMER_0;
config.pin_d0 = Y2_GPIO_NUM;
config.pin_d1 = Y3_GPIO_NUM;
config.pin_d2 = Y4_GPIO_NUM;
config.pin_d3 = Y5_GPIO_NUM;
config.pin_d4 = Y6_GPIO_NUM;
config.pin_d5 = Y7_GPIO_NUM;
config.pin_d6 = Y8_GPIO_NUM;
config.pin_d7 = Y9_GPIO_NUM;
config.pin_xclk = XCLK_GPIO_NUM;
config.pin_pclk = PCLK_GPIO_NUM;
config.pin_vsync = VSYNC_GPIO_NUM;
config.pin_href = HREF_GPIO_NUM;
config.pin_sccb_sda = SIOD_GPIO_NUM;
config.pin_sccb_scl = SIOC_GPIO_NUM;
config.pin_pwdn = PWDN_GPIO_NUM;
config.pin_reset = RESET_GPIO_NUM;
config.xclk_freq_hz = 8000000;
config.frame_size = FRAMESIZE_240X240;
config.pixel_format = PIXFORMAT_JPEG; // for streaming
config.grab_mode = CAMERA_GRAB_WHEN_EMPTY;
config.fb_location = CAMERA_FB_IN_PSRAM;
config.jpeg_quality = 12;
config.fb_count = 2;
// if PSRAM IC present, init with UXGA resolution and higher JPEG quality
// for larger pre-allocated frame buffer.
if (config.pixel_format == PIXFORMAT_JPEG) {
if (psramFound()) {
config.jpeg_quality = 10;
config.fb_count = 2;
config.grab_mode = CAMERA_GRAB_LATEST;
} else {
// Limit the frame size when PSRAM is not available
config.frame_size = FRAMESIZE_SVGA;
config.fb_location = CAMERA_FB_IN_DRAM;
}
} else {
// Best option for face detection/recognition
config.frame_size = FRAMESIZE_240X240;
#if CONFIG_IDF_TARGET_ESP32S3
config.fb_count = 2;
#endif
}
// camera init
esp_err_t err = esp_camera_init(&config);
if (err != ESP_OK) {
Serial.printf("Camera init failed with error 0x%x", err);
return;
}
sensor_t *s = esp_camera_sensor_get();
// initial sensors are flipped vertically and colors are a bit saturated
if (s->id.PID == OV3660_PID) {
s->set_vflip(s, 1); // flip it back
s->set_brightness(s, 1); // up the brightness just a bit
s->set_saturation(s, -2); // lower the saturation
}
// drop down frame size for higher initial frame rate
if (config.pixel_format == PIXFORMAT_JPEG) {
s->set_framesize(s, FRAMESIZE_QVGA);
}
}
No entanto, fiz uma alteração importante. Notei que erros “cam_hal: FB-OVF” eram impressos no Serial Monitor quando estava a executar o Vision Chatbot.
“FB-OVF” significa Frame Buffer Overflow. Essencialmente quer dizer que a câmara está a enviar dados mais rápido do que o ESP32 consegue processar ou armazenar na memória.
O método recomendado para evitar este erro é reduzir a taxa de frames. Por isso, alterei o código original da câmara e defini a taxa de frames para 8Mhz:
config.xclk_freq_hz = 8000000;
Isso eliminou os erros “cam_hal: FB-OVF”.
Pasta do Projeto
Pode descarregar o ficheiro completo do projeto vision_chatbot.zip ou criar o projeto Arduino para o Vision Chatbot você mesmo. Para isso, crie uma pasta “vision_chatbot” com dois ficheiros (“camera.h”, “vision_chatbot.ino”) dentro:

O ficheiro “camera.h” contém o código para a câmara e o “vision_chatbot.ino” contém o ficheiro para o Vision Chatbot. Depois de definir a placa correta (“ESP32S3 Dev Module”) e as configurações corretas da ferramenta (PSRAM, Huge APP, …) pode gravar o código na placa e desfrutar do seu Vision Chatbot em ação. As próximas duas secções mostram dois exemplos com as respostas do Vision Chatbot.
Exemplo: O que vês?
Neste primeiro exemplo, mostrei à ESP32-S3 AI Camera um pequeno modelo de crânio na minha secretária com algumas outras coisas ao fundo.

E aqui está a saída do Vision Chatbot no Serial Monitor quando apresentado com esta imagem:
13:22:02.672 -> E (1763) i2s_common: i2s_channel_disable(1217): the channel has not been enabled yet 13:22:03.049 -> Ready 13:25:49.615 -> [REC] WAV buffer allocated in PSRAM (96044 bytes) 13:25:49.615 -> [REC] Recording started 13:25:51.288 -> [REC] Recording stopped, 55724 bytes total 13:25:51.288 -> [STT] Building multipart body 13:25:51.288 -> [HEAP] Internal heap free before TLS: 181256 13:25:51.288 -> [STT] Multipart body in PSRAM (55944 bytes) 13:25:51.288 -> [STT] Uploading 55944 bytes 13:25:52.847 -> [STT] HTTP code: 200 13:25:52.847 -> [STT] What do you see? 13:25:52.847 -> [VISION] Encoding image... 13:25:52.847 -> [VISION] Sending request... 13:25:54.995 -> [VISION] HTTP code: 200 13:25:55.032 -> [VISION] I see a small skull placed on a light-colored surface, likely a table. In the background, there are various objects and possibly some clutter. The setting appears to be an indoor space.
Pode ver a pergunta feita “O que vês?” e a resposta do Chatbot “Vejo um pequeno crânio colocado numa superfície de cor clara, provavelmente uma mesa. Ao fundo, há vários objetos e possivelmente alguma desordem. O ambiente parece ser um espaço interior.”
Note que há uma mensagem de erro “E (1763) i2s_common: i2s_channel_disable(1217): the channel has not been enabled yet” no início que pode ignorar. Parece estar relacionada com um problema no core atual do ESP32, mas não afeta o funcionamento do Chatbot.
Exemplo: Quantos relógios?
Também pode perguntar ao Chatbot por objetos específicos numa imagem. Isso nem sempre funciona, e o Bot teve dificuldades em reconhecer cadeiras, por exemplo. No entanto, na cena relativamente complexa mostrada abaixo que contém um relógio de parede, o Chatbot indicou corretamente que há um relógio:

Aqui está a saída no Serial Monitor. Perguntei “Quantos relógios há nesta imagem?” e a resposta foi “Há um relógio visível na imagem.”:
15:25:00.656 -> Ready 15:28:28.984 -> [REC] WAV buffer allocated in PSRAM (96044 bytes) 15:28:28.984 -> [REC] Recording started 15:28:31.817 -> [REC] Recording stopped, 92204 bytes total 15:28:31.817 -> [STT] Building multipart body 15:28:31.817 -> [HEAP] Internal heap free before TLS: 181256 15:28:31.817 -> [STT] Multipart body in PSRAM (92424 bytes) 15:28:31.817 -> [STT] Uploading 92424 bytes 15:28:33.445 -> [STT] HTTP code: 200 15:28:33.445 -> [STT] How many clocks are in this image? 15:28:33.445 -> [VISION] Encoding image... 15:28:33.445 -> [VISION] Sending request... 15:28:34.953 -> [VISION] HTTP code: 200 15:28:34.989 -> [VISION] There is one clock visible in the image.
Considerando a baixa qualidade, o pequeno tamanho da imagem e que há muitos objetos na imagem, o Vision Chatbot foi realmente bom a encontrar o relógio.
Divirta-se a brincar com o seu Vision Chatbot, mas tenha atenção aos custos da OpenAI e ao seu orçamento, especialmente se começar a usar modelos mais capazes, mas também mais caros.
Conclusões
Neste tutorial aprendeu a construir um Vision Chatbot usando o Módulo DFRobot ESP32-S3 AI Camera e a OpenAI. A gravação de áudio, reprodução e captura de imagem foram feitas localmente pelo ESP32, enquanto Text-To-Speech, Speech-To-Text e Análise de Imagem foram realizados remotamente pelos serviços da OpenAI.
O processamento remoto de dados de imagem e áudio permite usar modelos de IA muito mais poderosos do que os que poderiam ser executados localmente no ESP32. No entanto, isso requer uma ligação estável à internet para comunicar com os serviços cloud da OpenAI e adiciona latência e custo. Além disso, a privacidade é uma preocupação ao enviar imagens para servidores externos.
Se quiser realizar reconhecimento de fala simples localmente, dê uma vista de olhos nos tutoriais Getting started with Gravity Voice Recognition Module, Voice control with XIAO-ESP32-S3-Sense and Edge Impulse e Using the Voice Recognition Module V3 with Arduino.
Para fala-para-texto veja o Gravity Text-to-Speech Module Tutorial, que pode gerar fala localmente mas com qualidade limitada.
Para mais exemplos de código para a DFRobot ESP32-S3 AI Camera, consulte o DFRobot Wiki e o seu github repo.
Se tiver alguma dúvida, sinta-se à vontade para deixá-la na secção de comentários.
Boas experiências a criar ; )

