La DFRobot ESP32-S3 AI Camera (DFR1154) es una placa de desarrollo diseñada para proyectos de IA y visión. Cuenta con un microcontrolador ESP32-S3 e integra un módulo de cámara, micrófono y altavoz en una sola placa.
En este proyecto, crearemos un Chatbot de Visión usando la placa ESP32-S3 AI Camera y OpenAI. La placa captura imágenes y escucha preguntas habladas a través del micrófono. Luego convierte el habla en texto usando OpenAI. A continuación, envía tanto la imagen como la pregunta en texto a otro modelo de OpenAI para su análisis. Este modelo de IA procesa la entrada, genera una respuesta sobre la imagen y devuelve la respuesta en texto. Finalmente, usamos OpenAI una tercera vez para convertir el texto de la respuesta en voz y reproducirlo a través del altavoz.
El breve video a continuación muestra el Chatbot de Visión en acción. Sostengo la placa en la mano izquierda y su cámara apunta a un pequeño modelo de cráneo sobre mi escritorio. Presiono el botón BOOT en la placa para iniciar el Chatbot de Visión y escucharás mi pregunta y la respuesta del bot.
Ajusta el volumen si no escuchas el audio y ten en cuenta que hay un retraso de unos segundos entre la pregunta y la respuesta debido a la transferencia de datos y el procesamiento en OpenAI:
La grabación de audio, la reproducción y la captura de imágenes se manejan localmente por la placa ESP32-S3 AI Camera. Pero el análisis de imágenes, la conversión de texto a voz (TTS) y de voz a texto (STT) utilizan los modelos de IA de OpenAI. Por lo tanto, se requiere conexión WiFi y una clave API de OpenAI.
Partes necesarias
Para este proyecto uso el Módulo de Cámara AI ESP32-S3 (DFR1154) de DFRobot. Puedes conseguirlo en DFRobot usando los enlaces a continuación. Asegúrate de obtener la Versión 1.1 y no la versión anterior 1.0. También podrías necesitar un cable USB-C.

DFRobot ESP32-S3 AI Camera

Cable 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 de la DFRobot AI Camera
La DFRobot ESP32-S3 AI Camera (DFR1154) es una placa compacta de desarrollo embebido para visión e IA construida alrededor del microcontrolador ESP32-S3 de Espressif. Integra conectividad inalámbrica, captura de cámara, entrada/salida de audio y capacidades de aceleración IA en un solo módulo. Esta placa está diseñada para tareas de computación en el borde como reconocimiento de objetos, interacción por voz y análisis visual en tiempo real.
Su tamaño físico es una placa de circuito impreso cuadrada de aproximadamente 42 mm por lado, lo que la hace adecuada para integración en robótica, sensores inteligentes y sistemas de monitoreo IoT. La imagen a continuación muestra el frente y el reverso de la placa:

Microcontrolador y Arquitectura de Memoria
En el núcleo de la placa, el microcontrolador ESP32-S3 ejecuta el código de la aplicación y maneja las comunicaciones. Este microcontrolador cuenta con una CPU Tensilica Xtensa® dual-core de 32 bits LX7 que corre hasta 240 MHz. Incluye SRAM embebida para acceso rápido a datos e instrucciones, así como SRAM RTC dedicada para operaciones de reloj de bajo consumo.
La memoria interna se complementa con almacenamiento externo: la placa ofrece 16 MB de memoria flash para firmware y datos, y un chip PSRAM de 8 MB para soportar montones de ejecución más grandes requeridos para el almacenamiento en búfer de imágenes o la ejecución de modelos IA. La interfaz USB cumple con USB 2.0 OTG a velocidad completa, permitiendo tanto suministro de energía como transferencia de datos.
Subsistema de Cámara
El subsistema de imagen se centra en un sensor de cámara OmniVision OV3660. Este sensor captura imágenes de hasta 2 megapíxeles e incluye sensibilidad tanto a luz visible como a infrarrojo de 940 nm, lo que amplía el rango operativo a condiciones de poca luz o visión nocturna.
La óptica ofrece un campo de visión de aproximadamente 160 grados, y el sistema de enfoque fijo tiene una distancia focal de alrededor de 0.95 mm con una apertura cercana a f/2.0. También hay cuatro LEDs IR para iluminación.
Conectividad inalámbrica
La placa soporta comunicación inalámbrica en modo dual. Para conectividad de red local, implementa Wi-Fi IEEE 802.11b/g/n en la banda de 2.4 GHz, con soporte para canales de 20 MHz y 40 MHz y múltiples modos operativos incluyendo estación, punto de acceso suave y modos combinados estación+AP. Bluetooth está disponible según los protocolos Bluetooth 5 y Bluetooth Mesh, permitiendo comunicación entre pares de bajo consumo o participación en redes de sensores.
Interfaces de audio y sensores
Además de la imagen, la placa tiene un micrófono PDM I2S integrado para captura de audio, que se enruta a través de un amplificador interno (MAX98357) y se expone a una interfaz dedicada para altavoz. Además, hay un puerto para tarjeta SD que permite almacenar datos de audio y video.
El sensor de luz ambiental LTR-308 permite ajustar la imagen o el consumo de energía según la iluminación ambiental. Esto es especialmente útil junto con los 4 LEDs IR presentes para iluminación.
La placa ofrece una interfaz Gravity de 4 pines (3.3V, GND, GPIO44/RX, GPIO43/TX) que proporciona conectividad UART/I2C sencilla para periféricos o sensores externos. Nota que en la versión anterior V1.0 de la placa el pin 1 de la interfaz Gravity era una entrada de 3.3-5V. Pero en la versión actual V1.1 el pin 1 es una salida de 3.3V.
Pines GPIO
La tabla a continuación lista los pines GPIO y su asignación a los diferentes componentes de hardware como cámara (CAM), micrófono (MIC), amplificador de audio (MAX98357), sensor de luz ambiental (ALS), tarjeta SD (SD), LEDs IR, botón BOOT y LED de estado:

Gestión de energía y diseño físico
La DFR1154 acepta múltiples configuraciones de alimentación. Opera nominalmente a 3.3 V, y puede recibir alimentación vía conector USB-C a 5 V DC o vía conector VIN con un rango más amplio de 3.7 V a 15 V DC. Un IC dedicado de gestión de energía (HM6245) regula estas entradas a los voltajes centrales requeridos.
El rango de temperatura operativo es de aproximadamente -10 °C hasta 60 °C, pensado para entornos interiores estándar o exteriores protegidos.
Especificaciones técnicas
La siguiente tabla resume las especificaciones clave de hardware del módulo DFRobot ESP32-S3 AI Camera (DFR1154) (source):
| Especificación | Detalles |
|---|---|
| Microcontrolador | ESP32-S3R8 con CPU dual-core Tensilica Xtensa LX7, 240 MHz |
| SRAM | 512 KB |
| ROM | 384 KB |
| Flash externa | 16 MB |
| PSRAM externa | 8 MB |
| RTC SRAM | 16 KB |
| Sensor de cámara | OV3660, 2 MP, 160° FoV, soporte infrarrojo |
| Óptica | Focal fija 0.95 mm, apertura f/2.0, distorsión <8 % |
| Inalámbrico | Wi-Fi 802.11b/g/n (2.4 GHz); Bluetooth 5 & Mesh |
| Audio | Micrófono I2S PDM; interfaz de altavoz vía amplificador MAX98357 |
| Sensores | Sensor de luz ambiental LTR-308 |
| USB | USB 2.0 OTG Full Speed (Tipo-C) |
| Alimentación | Voltaje operativo 3.3 V; USB-C 5 V; VIN 3.7–15 V |
| Botones | Reset y Boot |
| Dimensiones | 42 mm × 42 mm |
| Temperatura operativa | -10 °C a 60 °C |
Obtener clave API de OpenAI
El Chatbot de Visión usa modelos de IA proporcionados por OpenAI. Por lo tanto, necesitarás una cuenta de OpenAI. Ve a https://platform.openai.com y regístrate con una dirección de correo electrónico o una cuenta existente de Google o Microsoft.
Después de verificar tu correo y completar la configuración inicial, inicia sesión en el panel de OpenAI, platform.openai.com/api-keys y encuentra o crea tu clave API (=CLAVE SECRETA) como se muestra a continuación:

La clave API es una cadena única y larga, que comienza con «sk-proj-«, necesaria para autenticar tus solicitudes API (ver más abajo). Luego deberás copiar esta cadena completa en el código del Chatbot de Visión.
sk-proj-xcA.......................OtDu0U
Eso es todo lo que realmente necesitas, pero recomiendo establecer un límite de uso para tu cuenta también. Esto asegura que no termines con una factura costosa por un error en tu código (por ejemplo, enviando cientos de imágenes).
Puedes establecer límites de uso y también consultar los precios (costos) de los diferentes modelos de IA en la pestaña de facturación (platform.openai.com/settings/organization/billing).

Yo mismo establecí un límite de uso de 20 USD y no activé la recarga automática. Ese pequeño presupuesto dura mucho tiempo, siempre que se usen los modelos de IA más económicos. Como puedes ver, mi saldo aún es de 14 USD y he estado probando los modelos de OpenAI durante varios meses.
Instalar núcleo ESP32
Si es tu primer proyecto con una placa de la serie ESP32, también necesitarás instalar el núcleo ESP32. Si ya tienes placas ESP32 instaladas en tu Arduino IDE, puedes saltarte esta sección.
Comienza abriendo el diálogo de Preferencias seleccionando “Preferences…” en el menú “File”. Esto abrirá el diálogo de Preferencias mostrado a continuación.
En la pestaña Settings encontrarás un cuadro de edición en la parte inferior del diálogo etiquetado como “Additional boards manager URLs“:

En este campo copia la siguiente URL:
https://espressif.github.io/arduino-esp32/package_esp32_dev_index.json
Esto permitirá que el Arduino IDE sepa dónde encontrar las librerías del núcleo ESP32. Luego instalaremos las placas ESP32 usando el Gestor de Placas.
Abre el Gestor de Placas vía «Tools -> Boards -> Board Manager». Verás el Gestor de Placas en la barra lateral izquierda. Escribe «ESP32» en el campo de búsqueda superior y deberías ver dos tipos de placas ESP32; las «Arduino ESP32 Boards» y las placas «esp32 by Espressif». Queremos las «esp32 libraries by Espressif». Haz clic en el botón INSTALL y espera a que la descarga e instalación finalicen.

Estoy usando la versión actual 3.3.5 aquí, pero cualquier otra versión 3.x debería funcionar también para este proyecto.
Seleccionar placa
También necesitas seleccionar una placa ESP32. En el caso de la DFRobot ESP32-S3 AI Camera, puedes elegir la genérica «ESP32S3 Dev Module». Para ello, haz clic en el menú desplegable y luego en «Select other board and port…»:

Esto abrirá un diálogo donde puedes escribir «esp32s3 dev» en la barra de búsqueda. Verás la placa «ESP32S3 Dev Module» bajo Boards. Haz clic en ella y en el puerto COM para activarla y luego haz clic en OK:

Ten en cuenta que necesitas conectar la placa mediante el cable USB a tu ordenador antes de poder seleccionar un puerto COM.
Configuración de herramientas
A continuación están las configuraciones que debes usar con la placa. Las encontrarás en el menú Tools de tu Arduino IDE.

Las configuraciones más importantes son «16MB Flash Size», «Huge APP partition» y «OPI PSRAM». Para ver la salida de texto en el Monitor Serial asegúrate de que USB CDC on Boot esté «Enabled». Las otras configuraciones suelen ser las predeterminadas y están bien así.
Instalar librerías
El código para el Chatbot de Visión usa dos librerías, la ArduinoJson y la ESP32-audioI2S. Abre el LIBRARY MANAGER, busca «ArduinoJson» y «ESP32-audioI2S» y presiona el botón INSTALL para instalar estas librerías:

Como puedes ver, instalé la versión 3.4.5 de la librería ESP32-audioI2S y la versión 7.4.2 de ArduinoJson. Sin embargo, la versión exacta no debería importar mucho.
Código para el Chatbot de Visión
En esta sección te muestro el código para el Chatbot de Visión. Es una reimplementación del ejemplo OpenAI image recognition que puedes encontrar en DFRobot github repo pero que desafortunadamente no funciona de forma fiable. Por eso lo reescribí completamente.
El código comienza esperando a que se presione el botón BOOT y graba audio hasta que se suelta el botón BOOT o pasan más de 3 segundos. El botón BOOT está en la esquina inferior derecha en la parte trasera de la placa, encima del botón reset (RST):

Luego el código envía el audio grabado al modelo de texto a voz de OpenAI para transcripción, captura una imagen con la cámara y luego consulta al modelo GPT con capacidad de visión de OpenAI para responder preguntas sobre la imagen. La respuesta se convierte en audio mediante el modelo de voz a texto de OpenAI y se reproduce a través del pequeño altavoz del módulo ESP32-S3 AI Camera.
Echa un vistazo rápido al código antes de que discutamos los detalles. Si no quieres copiar y pegar el código, puedes descargar el archivo vision_chatbot.zip que contiene el código completo. Sin embargo, aún tendrás que configurar las credenciales WiFi y la clave API de OpenAI en el código.
#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);
}
Imports
El código comienza incluyendo las librerías necesarias para conectividad WiFi, comunicación HTTP segura, análisis JSON, control de cámara, procesamiento de audio y codificación base64. Estas librerías permiten que el ESP32-S3 interactúe con periféricos de hardware y se comunique con las APIs en la nube de 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"
Pines y configuración de audio
Varias constantes definen los pines GPIO usados para el botón, LED, datos y reloj del micrófono, y pines de salida de audio I2S. También se definen parámetros de audio como tasa de muestreo, tiempo máximo de grabación y tamaño del encabezado WAV para configurar la captura y reproducción de audio.
#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 y credenciales de red
El código especifica los modelos de OpenAI usados para texto a voz (TTS), voz a texto (STT) y respuesta a preguntas visuales. También almacena el SSID WiFi, contraseña y clave API de OpenAI como constantes para la conexión de red y autenticación 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";
Debes reemplazar los valores ficticios para ssid, password y apiKey de OpenAI con los valores correctos de tu red WiFi y tu clave API de OpenAI. De lo contrario, el Chatbot de Visión no podrá comunicarse con los modelos de IA de OpenAI y no funcionará.
Variables y objetos globales
El código declara un cliente WiFi seguro para comunicación HTTPS, un objeto de interfaz de audio I2S y un objeto Audio para reproducción. También gestiona un búfer para almacenar datos WAV grabados, banderas para estados de grabación y ocupado, y variables de tiempo para controlar la duración de la grabación.
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;
Inicialización del micrófono
La función initMic() configura el periférico I2S para recibir datos del micrófono PDM usando los pines de reloj y datos especificados. Establece la tasa de muestreo, ancho de bits de datos y modo mono para preparar la grabación de audio.
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);
}
Control de grabación de audio
La función startRecording() inicia la captura de audio encendiendo el LED indicador y asignando un búfer en PSRAM para contener los datos WAV. Escribe un encabezado WAV por defecto en el búfer, registra el tiempo de inicio y activa la bandera de grabación.
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");
}
La función pollRecording() lee los datos de audio disponibles del periférico I2S y los añade al búfer WAV. Si el búfer se llena, detiene la grabación automáticamente.
void pollRecording() {
size_t avail = I2S.available();
if (!avail) return;
if (wavSize + avail > wavMax) {
stopRecording();
return;
}
wavSize += I2S.readBytes((char*)(wavBuf + wavSize), avail);
}
La función stopRecording() finaliza los datos WAV actualizando el encabezado con los tamaños correctos, desactiva la bandera de grabación y apaga el 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);
}
Construcción de solicitud multipart para voz a texto
Para enviar el audio grabado al modelo de voz a texto de OpenAI, el código construye un cuerpo de solicitud HTTP multipart/form-data. Las funciones buildMultipartHead() y buildMultipartTail() crean los límites y encabezados multipart, mientras que buildMultipartBody() ensambla el cuerpo completo de la solicitud en PSRAM concatenando la cabecera, los datos WAV y el pie.
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;
}
La constante STT_MODEL especifica el modelo de voz a texto que se usa. Aquí uso «whisper-1» pero OpenAI tiene otros modelos Speech-to-Text como «gpt-4o-mini-transcribe», «gpt-4o-transcribe» o «gpt-4o-transcribe-diarize» que podrías probar.
POST HTTP para voz a texto
La función postMultipart() realiza la solicitud HTTPS POST al endpoint de transcripción de audio de OpenAI. Establece los encabezados de autorización y tipo de contenido, sube el cuerpo multipart y obtiene la respuesta. La función devuelve el código de estado HTTP y almacena la cadena de respuesta.
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;
}
Procesamiento de voz a texto
La función speechToText() orquesta el proceso de construir la solicitud multipart, enviarla y analizar la respuesta JSON para extraer el texto transcrito. También maneja la gestión de memoria liberando el búfer WAV después de enviar.
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);
}
Respuesta a preguntas visuales
La función visionAnswer() envía una pregunta junto con una imagen capturada al modelo GPT-4o-mini de OpenAI para respuesta a preguntas basadas en visión. Codifica la imagen en base64, construye una solicitud JSON de chat con mensajes de sistema y usuario, y analiza la respuesta para extraer la respuesta del asistente.
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"] | "";
}
La constante VISION_MODEL especifica el modelo OpenAI vision model usado. Uso «gpt-4o-mini» pero hay otros como «gpt-image-1», «gpt-5-mini», «gpt-5-nano» o «gpt-4.1-nano» que podrías probar. Tienen diferentes capacidades, velocidades y costos.
Reproducción de texto a voz
La función textToSpeech() usa la librería ESP32-audioI2S para solicitar síntesis de voz al modelo TTS de OpenAI. Activa la bandera de ocupado para evitar operaciones superpuestas mientras se reproduce audio.
void textToSpeech(String text) {
busy = true;
audio.openai_speech(apiKey, TTS_MODEL,
text.c_str(), "",
TTS_VOICE, "mp3", "1");
}
La constante TTS_MODEL especifica el modelo de texto a voz que se usa. Uso «tts-1» pero también podrías usar «tts-1-hd». El modelo «tts-1» ofrece menor latencia, pero con calidad inferior al modelo «tts-1-hd». Un modelo más inteligente pero también más caro es el modelo «gpt-4o-mini-tts» que también podrías usar.
La voz para la salida de audio se especifica con la constante TTS_VOICE, que está configurada en «shimmer». Puedes probar otras voces, pero ten en cuenta que la disponibilidad de voces depende del modelo. Los modelos tts-1 y tts-1-hd soportan un conjunto más pequeño de voces: «alloy», «ash», «coral», «echo», «fable», «onyx», «nova», «sage» y «shimmer» (platform.openai.com/docs/guides/text-to-speech).
Captura de cámara
La función captureImage() captura un fotograma de la cámara ESP32 y devuelve un puntero al búfer del fotograma para procesamiento posterior.
camera_fb_t* captureImage() {
return esp_camera_fb_get();
}
Inicialización de hardware
Varias funciones auxiliares inicializan componentes de hardware y servicios del sistema. initSpeaker() configura los pines de salida de audio y el volumen. initPins() configura los modos GPIO del botón y LED. initTime() sincroniza la hora del sistema usando servidores NTP. initWiFi() conecta a la red WiFi especificada. initSerial() inicia la comunicación serial para depuración.
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);
}
Función Setup
La función setup() se llama una vez al inicio. Configura el cliente seguro para omitir la verificación de certificados, inicializa la comunicación serial, pines GPIO, WiFi, hora del sistema, micrófono, altavoz y cámara. Finalmente, imprime «Ready» para indicar que el sistema está preparado.
void setup() {
secureClient.setInsecure();
initSerial();
initPins();
initWiFi();
initTime();
initMic();
initSpeaker();
initCamera();
Serial.println("Ready");
}
Función Loop
La función loop() se ejecuta repetidamente. Procesa la reproducción de audio en segundo plano. Si el sistema está ocupado reproduciendo audio, espera. De lo contrario, lee el estado del botón para detectar pulsaciones y liberaciones.
Cuando se presiona el botón, comienza la grabación. Mientras graba, se consulta y añade audio al búfer. La grabación se detiene cuando se suelta el botón o se alcanza el tiempo máximo de grabación.
Después de detenerse, el audio grabado se envía para transcripción. Si la transcripción tiene éxito, la cámara captura una imagen y la pregunta se envía al modelo de visión. La respuesta se convierte en voz y se reproduce. El bucle incluye un pequeño retardo para evitar rebotes del botón.
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);
}
Y este es el código completo para el Chatbot de Visión en sí. Sin embargo, también necesitamos algo de código para la cámara, que se describe en la siguiente sección.
Código Camera.h
El código de la cámara es esencialmente una copia del archivo «camera.h» del ejemplo OpenAI image recognition que puedes encontrar en 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);
}
}
Sin embargo, hice un cambio importante. Noté que se imprimían errores «cam_hal: FB-OVF» en el Monitor Serial cuando ejecutaba el Chatbot de Visión.
«FB-OVF» significa Desbordamiento del búfer de fotogramas. Básicamente indica que la cámara está enviando datos más rápido de lo que el ESP32 puede procesar o almacenar en memoria.
El método recomendado para evitar este error es reducir la tasa de cuadros. Por eso cambié el código original de la cámara y establecí la tasa de cuadros a 8 MHz:
config.xclk_freq_hz = 8000000;
Eso eliminó los errores «cam_hal: FB-OVF».
Carpeta del proyecto
Puedes descargar el archivo completo del proyecto vision_chatbot.zip o crear el proyecto Arduino para el Chatbot de Visión tú mismo. Para ello crea una carpeta «vision_chatbot» con dos archivos («camera.h», «vision_chatbot.ino») dentro:

El archivo «camera.h» contiene el código para la cámara y «vision_chatbot.ino» contiene el código para el Chatbot de Visión. Una vez que hayas configurado la placa correcta («ESP32S3 Dev Module») y las configuraciones de herramientas adecuadas (PSRAM, Huge APP, …) puedes cargar el código en la placa y disfrutar de tu Chatbot de Visión en acción. Las siguientes dos secciones muestran dos ejemplos con las respuestas del Chatbot de Visión.
Ejemplo: ¿Qué ves?
En este primer ejemplo, mostré a la ESP32-S3 AI Camera un pequeño modelo de cráneo sobre mi escritorio con otras cosas en el fondo.

Y aquí está la salida del Chatbot de Visión en el Monitor Serial cuando se le presentó esta imagen:
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.
Puedes ver la pregunta «¿Qué ves?» y la respuesta del Chatbot «Veo un pequeño cráneo colocado sobre una superficie de color claro, probablemente una mesa. En el fondo hay varios objetos y posiblemente algo de desorden. El entorno parece ser un espacio interior.»
Ten en cuenta que hay un mensaje de error «E (1763) i2s_common: i2s_channel_disable(1217): the channel has not been enabled yet» al principio que puedes ignorar. Parece estar relacionado con un problema del núcleo ESP32 actual pero no afecta la función del Chatbot.
Ejemplo: ¿Cuántos relojes?
También puedes preguntarle al Chatbot por objetos específicos en una imagen. Eso no funciona siempre, y el bot tuvo problemas para reconocer sillas, por ejemplo. Sin embargo, en la escena bastante compleja mostrada abajo que contiene un reloj de pared, el Chatbot informó correctamente que hay un reloj:

Aquí está la salida en el Monitor Serial. Pregunté «¿Cuántos relojes hay en esta imagen?» y la respuesta fue «Hay un reloj visible en la imagen.»:
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 la baja calidad, el pequeño tamaño de la imagen y que hay muchos objetos en ella, el Chatbot de Visión fue realmente bueno encontrando el reloj.
Diviértete jugando con tu Chatbot de Visión pero vigila los costos de OpenAI y tu presupuesto, especialmente si comienzas a usar modelos más potentes pero también más caros.
Conclusiones
En este tutorial aprendiste a construir un Chatbot de Visión usando el módulo DFRobot ESP32-S3 AI Camera y OpenAI. La grabación de audio, reproducción y captura de imágenes se realizaron localmente en el ESP32, mientras que la conversión de texto a voz, voz a texto y análisis de imágenes se realizaron remotamente mediante los servicios de OpenAI.
El procesamiento remoto de datos de imagen y audio nos permite usar modelos de IA mucho más potentes que los que podrían ejecutarse localmente en el ESP32. Sin embargo, esto requiere una conexión a internet estable para comunicarse con los servicios en la nube de OpenAI y añade latencia y costo. Además, la privacidad es una preocupación al enviar imágenes a servidores externos.
Si quieres realizar reconocimiento de voz simple localmente, echa un vistazo a los tutoriales Getting started with Gravity Voice Recognition Module, Voice control with XIAO-ESP32-S3-Sense and Edge Impulse y Using the Voice Recognition Module V3 with Arduino.
Para voz a texto, consulta el tutorial Gravity Text-to-Speech Module Tutorial, que puede generar voz localmente pero con calidad limitada.
Para más ejemplos de código para la DFRobot ESP32-S3 AI Camera, revisa DFRobot Wiki y su github repo.
Si tienes alguna pregunta, no dudes en dejarla en la sección de comentarios.
¡Feliz bricolaje ; )

