Community project

App-Controlled Animated Head

Photo of App-Controlled Animated Head
Generated with AI

Rhys Rabaiotti

Published November 9, 2026

Build an app-controlled animated head that combines servo-driven motion with a colorful TFT face display and audio output. The ESP32 microcontroller manages two MG90S servos for pan and tilt movement, renders expressive animations on a 2-inch ST7789 display, and plays audio through a MAX98357A amplifier and 8Ω speaker.

This guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions that prioritize safe power delivery with protection circuitry. You'll configure the ESP32 firmware to accept motion and expression commands over serial or wireless connection, test each subsystem independently, and integrate everything into a responsive character that moves and speaks on demand.

Wiring diagram

Wiring diagram for App-Controlled Animated Head
Loading diagram…

Gather all the parts

QtyComponent
1

MG90S Micro Servo

Tower Pro MG90S 9g metal-gear micro servo motor. Controlled via a standard 50 Hz PWM signal (1–2 ms pulse width). Operating voltage 4.8 V–6 V. Three-wire interface: VCC (red), GND (brown/black), and Signal (orange/yellow). Stall torque 1.8 kg·cm at 4.8 V / 2.2 kg·cm at 6 V. Rotation range 0°–180°.

1

MG90S Micro Servo

Tower Pro MG90S 9g metal-gear micro servo motor. Controlled via a standard 50 Hz PWM signal (1–2 ms pulse width). Operating voltage 4.8 V–6 V. Three-wire interface: VCC (red), GND (brown/black), and Signal (orange/yellow). Stall torque 1.8 kg·cm at 4.8 V / 2.2 kg·cm at 6 V. Rotation range 0°–180°.

1

8Ω Speaker

8 Ω, 1–3 W

Generic small 8Ω 0.5-3W loudspeaker (~28mm typical). Pair with an I2S amp (MAX98357A) or class-D amp (TPA3116D2) for usable volume; do not drive directly from a GPIO pin. Audio output for music/voice playback.

1

USB-C 5V Adapter

regulated 5 V, 2 A or greater

USB-C wall adapter delivering regulated 5 V to the board's USB or VBUS rail. Default wired power source for desktop / stationary projects.

1

MAX98357A I2S Class-D mono amplifier breakout

A mono digital audio amplifier that receives sound data from the XIAO and drives Keith's small speaker.

1

ST7789 TFT Display 2.0 inch

ST7789 SPI IPS, 240×320 assumed; 240×280 uses one firmware constant

2.0-inch IPS TFT color display breakout driven by the ST7789 controller over 4-wire SPI. Native resolution is 320x240. Adafruit's breakout includes a 3.3V regulator, auto-reset circuit, 3V/5V level shifting, and a microSD holder sharing the SPI bus. Display drawing uses SCK, MOSI, CS, DC, and optional RST; MISO and SDCS are only needed for the onboard microSD card.

1

Electrolytic Capacitor

470 µF, 10 V or higher

Radial aluminium electrolytic capacitor bought by value and voltage rating, such as 10 µF, 100 µF or 1000 µF 10 V. It is polarised: the + lead goes to the higher voltage and the - lead, marked by the stripe on the sleeve, to the lower voltage or ground. It is mostly used for bulk filtering across a power rail, with a voltage rating comfortably above the rail.

1

1N5817 Schottky diode

A low-loss one-way power diode that prevents reversed supply wiring from reaching Keith's electronics.

1

USB-C 5 V power-only breakout

A USB-C socket breakout with built-in CC resistors that accepts 5 V from a USB-C wall charger.

Assemble it in 6 steps

1. Make the protected power rail

With every USB cable unplugged, connect the USB-C breakout VBUS terminal to the unbanded end of reverse_diode. The banded end is the protected power point. Connect the 470 µF capacitor with its long positive lead to that banded point and its striped negative lead to GND.

  • Do not reverse the capacitor: a reversed electrolytic capacitor can heat, leak, or burst.
  • Do not power the external rail while a normal USB cable also connects the XIAO to your Mac; the two 5 V sources can damage the Mac USB port.

2. Join all ground wires

Use a lever or screw terminal to join the USB-C breakout GND, capacitor negative lead, both servo brown or black wires, amplifier GND, display GND, and XIAO GND. This is the shared return path that makes every control signal work.

  • Make this ground join first and keep the servo power wires short and thick.

3. Connect protected power

From the diode’s banded end, run one wire to the XIAO 5V/VBUS pin, one to each servo red wire, and one to audio_amp VIN. Do not connect any servo red wire to XIAO 3V3. Connect face_tft VCC and BL to XIAO 3V3.

  • The 3.3 V pin powers only the screen logic; it must never power a servo.

4. Wire the face screen

Connect face_tft SCK to XIAO GPIO7/D8 (picture timing), MOSI to GPIO9/D10 (picture data), CS to GPIO44/D7 (screen select), DC to GPIO1/D0 (command/data), and RST to GPIO2/D1 (screen reset). Leave the screen MISO and SDCS disconnected unless the particular display has a card socket you later choose to use.

  • This deliberately leaves the Sense expansion camera, microphone, and onboard SD-card connections free.

5. Wire sound and motion

Connect audio_amp BCLK to GPIO5/D4 (sound timing), LRC to GPIO6/D5 (sound sample timing), and DIN to GPIO43/D6 (sound data). Connect amplifier SPK+ and SPK- directly to the two face_speaker terminals. Connect pan_servo orange/yellow signal to GPIO3/D2 and tilt_servo orange/yellow signal to GPIO4/D3.

  • Never connect either speaker lead to GND; the amplifier drives both speaker wires.
  • Leave both servo horns off for the first power-up so a wrong direction cannot force the printed neck.

6. Test in safe order

First test the screen with the servos unplugged. Next connect the speaker, then verify the built-in Sense microphone and camera in firmware, then plug in one servo at a time with its horn still loose. Fit each horn only after the home position is correct.

  • Use the external charger for powered tests; unplug it before connecting a normal USB cable to the Mac for flashing.

Review all connections

1. Connections between "power_supply" and "ESP32"

Functionpower_supplyESP32
groundGND → shared groundGND
power+5V → USB-C 5 V power-only breakout VBUSEXT

2. Connections between "reverse_diode" and "ESP32"

Functionreverse_diodeESP32
powerK → banded end is the protected 4.6–4.8 V railVIN
powerK → Electrolytic Capacitor +EXT
powerK → MG90S Micro Servo VCCEXT
powerK → MG90S Micro Servo VCCEXT
powerK → MAX98357A I2S Class-D mono amplifier breakout VINEXT

3. Connections between "bulk_cap" and "ESP32"

Functionbulk_capESP32
ground- → capacitor negative lead to groundGND

4. Connections between "pan_servo" and "ESP32"

Functionpan_servoESP32
groundGND → servo groundGND
pwmSIG → pan control signalGPIO 3

5. Connections between "tilt_servo" and "ESP32"

Functiontilt_servoESP32
groundGND → servo groundGND
pwmSIG → tilt control signalGPIO 4

6. Connections between "audio_amp" and "ESP32"

Functionaudio_ampESP32
groundGND → amplifier groundGND
digitalBCLK → I2S bit clockGPIO 5
digitalLRC → I2S word-select clockGPIO 6
digitalDIN → I2S audio dataGPIO 43
dataSPK+ → 8Ω Speaker POSEXT
dataSPK- → 8Ω Speaker NEGEXT

7. Connections between "face_tft" and "ESP32"

Functionface_tftESP32
powerVCC → 3.3 V display power3V3
groundGND → display groundGND
spiSCK → shared SD/display SPI clockGPIO 7
spiMOSI → shared SD/display SPI dataGPIO 9
spiCS → display select; leaves SD card pins freeGPIO 44
digitalDC → display command/data selectGPIO 1
digitalRST → display resetGPIO 2
powerBL → always-on display backlight3V3

8. Connections between "power_entry" and "ESP32"

Functionpower_entryESP32
powerVBUS → 1N5817 Schottky diode AEXT
groundGND → shared groundGND

Deploy the firmware

#include <Arduino.h>
#include <Arduino_GFX_Library.h>
#include "driver/i2s.h"
#include <math.h>

#define TFT_SCK 7
#define TFT_MOSI 9
#define TFT_CS 44
#define TFT_DC 1
#define TFT_RST 2
#define PAN_SERVO_PIN 3
#define TILT_SERVO_PIN 4
#define I2S_BCLK 5
#define I2S_LRC 6
#define I2S_DIN 43
#define SERIAL_BAUD 115200
#define AUDIO_SAMPLE_RATE 16000
#define SERVO_FREQUENCY 50
#define SERVO_RESOLUTION 16
#define SERVO_MIN_US 500
#define SERVO_MAX_US 2500

Arduino_DataBus *displayBus = new Arduino_ESP32SPI(TFT_DC, TFT_CS, TFT_SCK, TFT_MOSI, GFX_NOT_DEFINED);
Arduino_GFX *gfx = new Arduino_ST7789(displayBus, TFT_RST, 0, true);

int panAngle = 90;
int tiltAngle = 90;
String faceName = "NEUTRAL";
String commandLine;

uint32_t servoDutyFromAngle(int angle) {
  angle = constrain(angle, 20, 160);
  const uint32_t pulseUs = map(angle, 0, 180, SERVO_MIN_US, SERVO_MAX_US);
  return (pulseUs * 65535UL) / 20000UL;
}

void writeServo(int pin, int angle) {
  ledcWrite(pin, servoDutyFromAngle(angle));
}

void setPan(int angle) {
  panAngle = constrain(angle, 20, 160);
  writeServo(PAN_SERVO_PIN, panAngle);
}

void setTilt(int angle) {
  tiltAngle = constrain(angle, 20, 160);
  writeServo(TILT_SERVO_PIN, tiltAngle);
}

void drawEye(int x, int y, bool happy, bool sad) {
  const uint16_t eyeColor = 0xFFFF;
  if (happy) {
    gfx->fillCircle(x, y + 12, 34, eyeColor);
    gfx->fillRect(x - 40, y - 30, 80, 36, 0x0000);
  } else if (sad) {
    gfx->fillCircle(x, y - 12, 34, eyeColor);
    gfx->fillRect(x - 40, y - 6, 80, 36, 0x0000);
  } else {
    gfx->fillRoundRect(x - 30, y - 44, 60, 88, 28, eyeColor);
  }
}

void drawFace() {
  const bool happy = faceName == "HAPPY";
  const bool sad = faceName == "SAD";
  gfx->fillScreen(0x0000);
  drawEye(92, 165, happy, sad);
  drawEye(228, 165, happy, sad);
  if (happy) {
    gfx->drawArc(160, 285, 76, 52, 20, 160, 0xFFE0);
    gfx->drawArc(160, 286, 75, 51, 20, 160, 0xFFE0);
  } else if (sad) {
    gfx->drawArc(160, 350, 76, 52, 200, 340, 0xFFE0);
    gfx->drawArc(160, 349, 75, 51, 200, 340, 0xFFE0);
  } else {
    gfx->fillRoundRect(108, 300, 104, 16, 8, 0xFFE0);
  }
}

void initialiseAudio() {
  i2s_config_t config = {};
  config.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX);
  config.sample_rate = AUDIO_SAMPLE_RATE;
  config.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT;
  config.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT;
  config.communication_format = I2S_COMM_FORMAT_STAND_I2S;
  config.dma_buf_count = 8;
  config.dma_buf_len = 64;
  config.tx_desc_auto_clear = true;
  i2s_pin_config_t pins = {};
  pins.bck_io_num = I2S_BCLK;
  pins.ws_io_num = I2S_LRC;
  pins.data_out_num = I2S_DIN;
  pins.data_in_num = I2S_PIN_NO_CHANGE;
  i2s_driver_install(I2S_NUM_0, &config, 0, nullptr);
  i2s_set_pin(I2S_NUM_0, &pins);
  i2s_zero_dma_buffer(I2S_NUM_0);
}

void playBeep(uint16_t frequency = 660, uint16_t durationMs = 140) {
  const int samples = AUDIO_SAMPLE_RATE * durationMs / 1000;
  int16_t buffer[128];
  int made = 0;
  while (made < samples) {
    const int count = min(128, samples - made);
    for (int i = 0; i < count; ++i) {
      const float phase = 2.0f * PI * frequency * (made + i) / AUDIO_SAMPLE_RATE;
      buffer[i] = (int16_t)(sinf(phase) * 10000.0f);
    }
    size_t written = 0;
    i2s_write(I2S_NUM_0, buffer, count * sizeof(int16_t), &written, portMAX_DELAY);
    made += count;
  }
}

void sendState() {
  Serial.printf("STATE PAN=%d TILT=%d FACE=%s\n", panAngle, tiltAngle, faceName.c_str());
}

void handleCommand(String line) {
  line.trim();
  line.toUpperCase();
  if (line.startsWith("PAN=")) {
    setPan(line.substring(4).toInt());
    Serial.printf("OK PAN=%d\n", panAngle);
  } else if (line.startsWith("TILT=")) {
    setTilt(line.substring(5).toInt());
    Serial.printf("OK TILT=%d\n", tiltAngle);
  } else if (line.startsWith("FACE=")) {
    const String requestedFace = line.substring(5);
    if (requestedFace == "NEUTRAL" || requestedFace == "HAPPY" || requestedFace == "SAD") {
      if (requestedFace != faceName) {
        faceName = requestedFace;
        drawFace();
      }
      Serial.printf("OK FACE=%s\n", faceName.c_str());
    } else {
      Serial.println("ERR FACE must be NEUTRAL, HAPPY, or SAD");
    }
  } else if (line == "SOUND=BEEP") {
    playBeep();
    Serial.println("OK SOUND=BEEP");
  } else if (line == "STATE?") {
    sendState();
  } else if (line.length() > 0) {
    Serial.println("ERR Use PAN=20..160, TILT=20..160, FACE=NEUTRAL|HAPPY|SAD, SOUND=BEEP, or STATE?");
  }
}

void setup() {
  Serial.begin(SERIAL_BAUD);
  delay(300);
  ledcAttach(PAN_SERVO_PIN, SERVO_FREQUENCY, SERVO_RESOLUTION);
  ledcAttach(TILT_SERVO_PIN, SERVO_FREQUENCY, SERVO_RESOLUTION);
  setPan(panAngle);
  setTilt(tiltAngle);
  gfx->begin();
  drawFace();
  initialiseAudio();
  Serial.println("HEAD READY");
  sendState();
}

void loop() {
  while (Serial.available()) {
    const char incoming = (char)Serial.read();
    if (incoming == '\n' || incoming == '\r') {
      if (commandLine.length() > 0) {
        handleCommand(commandLine);
        commandLine = "";
      }
    } else if (commandLine.length() < 80) {
      commandLine += incoming;
    }
  }
  delay(1);
}

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.

Open in Schematik