Community project
AI Companion Head
Build an AI Companion Head that displays animated faces on an OLED screen and responds with synthesized speech through a speaker. This ESP32-based project receives commands from a PC over Wi-Fi, allowing the companion to show expressions and play audio generated by an AI model running on the host computer.
The guide provides a complete wiring diagram connecting the SSD1306 display, INMP441 microphone, MAX98357A amplifier, speaker, and MicroSD card module to the ESP32. Assembly steps cover safe wiring practices, firmware configuration for Wi-Fi and audio settings, and customization of facial animations through the provided C++ code.
Wiring diagram

Gather all the parts
Assemble it in 6 steps
1. Keep USB disconnected while wiring
Build the circuit on a breadboard or secure wiring harness first. Use the ESP32 USB port as the only power source for this version.
- Use short wires for I²S audio connections to reduce noise.
- All modules must share ESP32 GND.
- Do not power the INMP441 microphone from 5 V.
- Do not connect either speaker terminal to GND; the MAX98357A speaker outputs are a paired amplifier output.
2. Wire the OLED screen
Connect OLED VCC to ESP32 3V3 and OLED GND to ESP32 GND. Connect OLED SDA to GPIO21 and OLED SCL to GPIO22.
- This assumes the selected 128×64 I²C SSD1306 OLED with address 0x3C.
- If the screen remains blank, confirm its VCC/GND labels and I²C address before changing any wiring.
- Use 3.3 V for the OLED to keep its I²C signals safe for the ESP32.
3. Wire the I²S microphone
Connect INMP441 VDD to 3V3, GND to GND, and L/R to GND (left channel). Connect SCK to GPIO32, WS to GPIO33, and SD to GPIO16.
- Place the microphone away from the speaker so it is less likely to hear its own reply audio.
- INMP441 VDD must be 3.3 V maximum.
4. Wire amplifier and speaker
Connect MAX98357A VIN to ESP32 5V/VIN, and GND to ESP32 GND. Connect BCLK to GPIO26, LRC to GPIO25, and DIN to GPIO27. Connect amplifier SPK+ to speaker POS and amplifier SPK- to speaker NEG.
- The amplifier is supplied from USB's 5 V rail for useful speaker volume.
- Keep the two speaker leads together and separate from microphone wires where possible.
- Never connect SPK+ or SPK- to the ESP32, 5 V, or GND.
- Start with a 1 W or higher 8 Ω speaker.
5. Wire the microSD adapter
Connect SD adapter VCC to 3V3 and GND to GND. Connect MISO to GPIO19, MOSI to GPIO23, SCK to GPIO18, and CS to GPIO4. Insert a FAT32-formatted microSD card after wiring.
- Use an adapter designed for 3.3 V SPI logic, or one explicitly marked safe with 3.3 V ESP32 signals.
- Do not use a 5 V-only SD module with 3.3 V ESP32 signal pins.
6. Power the body and connect the PC
Connect USB power and use Schematik’s Deploy button. The ESP32 creates Wi-Fi named Companion-Body. Connect the PC running the companion program to that network, then let the PC send display-state, text, and audio commands to the ESP32 at 192.168.4.1.
- The network password and other body settings are in src/companion_settings.h.
- The PC program is the brain: it performs AI, speech recognition, text-to-speech, and decides what the OLED shows.
- Change the default Wi-Fi password in companion_settings.h before regular use.
- This SSD1306 screen is monochrome. The PC can select face animations and text, but it cannot mirror a colour picture or normal GIF exactly.
Review all connections
1. Connections between "oled_1" and "ESP32"
2. Connections between "mic_1" and "ESP32"
3. Connections between "amp_1" and "ESP32"
4. Connections between "sd_1" and "ESP32"
Deploy the firmware
// PC Mirror Companion — ESP32 body firmware
// GENERATE: the PC's AI sends state/text/audio commands below.
// MODIFY: edit face and animation drawing in companion_body.cpp.
// SETTINGS: edit Wi-Fi and audio values in companion_settings.h.
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include "driver/i2s.h"
#include "companion_settings.h"
#include "companion_body.h"
// Forward declarations
void startSpeaker();
void sendJson(const String &json);
void handleStatus();
void handleSet();
void handleSpeak();
void handleRoot();
constexpr int AMP_BCLK = 26;
constexpr int AMP_LRCLK = 25;
constexpr int AMP_DIN = 27;
WebServer server(BODY_HTTP_PORT);
void startSpeaker() {
i2s_config_t config = {};
config.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX);
config.sample_rate = PCM_SAMPLE_RATE;
config.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT;
config.channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT;
config.communication_format = I2S_COMM_FORMAT_I2S;
config.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1;
config.dma_buf_count = 4;
config.dma_buf_len = 128;
config.tx_desc_auto_clear = true;
i2s_driver_install(I2S_NUM_1, &config, 0, nullptr);
i2s_pin_config_t pins = {};
pins.bck_io_num = AMP_BCLK;
pins.ws_io_num = AMP_LRCLK;
pins.data_out_num = AMP_DIN;
pins.data_in_num = I2S_PIN_NO_CHANGE;
i2s_set_pin(I2S_NUM_1, &pins);
i2s_zero_dma_buffer(I2S_NUM_1);
}
void sendJson(const String &json) {
server.sendHeader("Access-Control-Allow-Origin", "*");
server.send(200, "application/json", json);
}
void handleStatus() {
String json = "{\"state\":\"" + bodyStateName() + "\",\"text\":\"" + bodyText() +
"\",\"ip\":\"" + WiFi.softAPIP().toString() + "\"}";
sendJson(json);
}
void handleSet() {
if (!server.hasArg("state") && !server.hasArg("text")) {
server.send(400, "text/plain", "Use state and/or text query values.");
return;
}
if (server.hasArg("state")) bodySetState(server.arg("state"));
if (server.hasArg("text")) bodySetText(server.arg("text"));
if (server.hasArg("contrast")) bodySetContrast((uint8_t)constrain(server.arg("contrast").toInt(), 0, 255));
handleStatus();
}
void handleSpeak() {
// PC TTS sends raw signed 16-bit, 16 kHz, mono PCM in the POST body.
if (server.method() != HTTP_POST) {
server.send(405, "text/plain", "POST raw 16-bit mono PCM data.");
return;
}
bodySetState("talking");
const String &raw = server.arg("plain");
const uint8_t *bytes = (const uint8_t *)raw.c_str();
for (size_t offset = 0; offset + 1 < raw.length(); offset += 2) {
int16_t sample = (int16_t)((uint16_t)bytes[offset] | ((uint16_t)bytes[offset + 1] << 8));
int16_t stereo[2] = {sample, sample};
size_t written;
i2s_write(I2S_NUM_1, stereo, sizeof(stereo), &written, portMAX_DELAY);
}
bodySetState("idle");
sendJson("{\"ok\":true}");
}
void handleRoot() {
server.send(200, "text/html", "<h2>PC Mirror Companion</h2><p>PC control endpoint ready.</p><p>GET /set?state=listening&text=Hello</p><p>GET /status</p>");
}
void setup() {
Serial.begin(115200);
bodyBegin();
startSpeaker();
WiFi.mode(WIFI_AP);
WiFi.softAP(BODY_AP_NAME, BODY_AP_PASSWORD);
bodySetText("Connect PC to Wi-Fi");
server.on("/", HTTP_GET, handleRoot);
server.on("/status", HTTP_GET, handleStatus);
server.on("/set", HTTP_GET, handleSet);
server.on("/speak", HTTP_POST, handleSpeak);
server.begin();
}
void loop() {
server.handleClient();
bodyTick();
}Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.




