Community project

Interactive AI Companion Robot

ESP32
Photo of Interactive AI Companion Robot
Generated with AI

Yogi Amanta

Published October 24, 2026

Build an interactive AI companion robot powered by an ESP32 that listens, thinks, and responds with expressive visuals and audio. This project combines a 240x280 LCD display for animated facial expressions, dual I²S microphones for stereo audio capture, and a Class-D amplifier with speaker for voice output, all coordinated through intelligent state management and real-time audio processing.

This guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions for integrating the display, microphone array, audio amplifier, RGB status LEDs, and battery management into a functional robot platform. Firmware code handles I²S audio streaming, expression rendering, and state transitions, with all GPIO mappings and configuration parameters documented for easy customization.

Wiring diagram

Wiring diagram for Interactive AI Companion Robot

Gather all the parts

QtyComponent
1

Waveshare 1.69inch LCD Module ST7789V2 240x280

Modul 8-pin GH1.25 Waveshare, bukan panel FPC telanjang. Daya dan logika 3.3 V. BL kontrol backlight modul; jangan sambungkan LED backlight panel langsung ke GPIO. https://www.waveshare.com/1.69inch-lcd-module.htm

1

HiLetgo INMP441 I²S Microphone Module

Omnidirectional 24-bit I²S MEMS microphone module based on the TDK InvenSense INMP441 IC. Outputs digital audio over a 3-wire I²S bus (SCK/BCLK, WS/LRCLK, SD/DOUT). Supply 1.8–3.3 V; native 3.3 V operation with no level shifting required. L/R channel select pin: tie to GND for left-channel mono output or VDD for right-channel mono output. Use the ESP32 Arduino core or ESP-IDF I2S peripheral API; no separate PlatformIO library is required.

1

HiLetgo INMP441 I²S Microphone Module

Omnidirectional 24-bit I²S MEMS microphone module based on the TDK InvenSense INMP441 IC. Outputs digital audio over a 3-wire I²S bus (SCK/BCLK, WS/LRCLK, SD/DOUT). Supply 1.8–3.3 V; native 3.3 V operation with no level shifting required. L/R channel select pin: tie to GND for left-channel mono output or VDD for right-channel mono output. Use the ESP32 Arduino core or ESP-IDF I2S peripheral API; no separate PlatformIO library is required.

1

MAX98357A I2S Class-D Mono Amplifier Breakout

I2S-input Class-D mono audio amplifier IC on a compact breakout board. Accepts I2S digital audio input (BCLK, LRC, DIN) and drives a small speaker or transducer directly. No I2C/SPI control bus is needed. The amplifier supply range is 2.5V-5.5V, and the I2S input pins are compatible with 3.3V logic. SD/MODE controls shutdown and channel selection; GAIN selects 3 dB, 6 dB, 9 dB, 12 dB, or 15 dB gain.

1

Speaker 4 ohm 3 W

Speaker pasif 4 ohm 3W; kedua terminal HANYA ke output diferensial amp, bukan GND.

1

WS2812B pixel breakout 5 V

Satu pixel WS2812B pada breakout berlabel VCC, GND, DIN, DOUT. Resistor pembatas arus RGB terintegrasi dalam IC; resistor seri eksternal pada jalur data.

1

WS2812B pixel breakout 5 V

Satu pixel WS2812B pada breakout berlabel VCC, GND, DIN, DOUT. Resistor pembatas arus RGB terintegrasi dalam IC; resistor seri eksternal pada jalur data.

1

SN74AHCT125N PDIP-14 level shifter

TI AHCT TTL-compatible buffer 3.3V to 5V. Angka pada nama adalah nomor kaki PDIP-14. OE aktif LOW. Output kanal tak terpakai dibiarkan terbuka.

1

Resistor 330 ohm seri data 1

Komponen pasif; lihat nilai dan koneksi.

1

Resistor 330 ohm seri data 2

Komponen pasif; lihat nilai dan koneksi.

1

100 kohm pulldown GPIO21

Komponen pasif; lihat nilai dan koneksi.

1

Tombol momentary normally-open

Dua kontak tombol. GPIO18 INPUT_PULLUP, ditekan = LOW.

1

100 kohm pulldown I2S SD

Komponen pasif; lihat nilai dan koneksi.

1

Protected 18650 3.7 V 3000 mAh, 4.2 V charge

WAJIB sel/pack dengan PCB proteksi bawaan overcharge, overdischarge, short circuit, arus kontinu >=3 A dan charge >=1 A. Terminal P+/P- adalah output TERPROTEKSI. Gunakan holder cocok panjang protected cell. Bukan sel polos. Proteksi terintegrasi tidak digantikan oleh charger.

1

Adafruit BQ24074 charger #4755

USB-C charger 1S 4.2V dengan power-path; 1A charge default, batas load 1.5A. USB-C 5V 3A ke soket modul, OUT maksimal 4.4V. Proteksi sel disediakan PCB dalam protected battery. Cut jumper THERM untuk NTC eksternal. https://learn.adafruit.com/adafruit-bq24074-universal-usb-dc-solar-charger-breakout/pinouts

1

Pololu U3V70F5 #2891 boost 5 V

Boost 2.9-5V input, output 5V. ENABLE ditarik HIGH internal, LOW memutus output (true shutdown). Converter berkemampuan >2A, tetapi charger membatasi input sistem ke 1.5A. https://www.pololu.com/product/2891

1

Saklar OFF boost, SPST

Tertutup = OFF (ENABLE ke GND); terbuka = ON. Pengisian baterai tetap bekerja saat OFF.

1

NTC 10 kohm Adafruit #372

Tempel terisolasi pada badan sel. Cut jumper THERM pada charger sebelum menghubungkan NTC.

1

100 kohm 1% pembagi atas

Komponen pasif; lihat nilai dan koneksi.

1

100 kohm 1% pembagi bawah

Komponen pasif; lihat nilai dan koneksi.

1

100 nF filter ADC

Kapasitor bypass/filter; untuk elektrolit, kaki - wajib ke GND. Pasang dekat beban terkait.

1

100 nF bypass AHCT

Kapasitor bypass/filter; untuk elektrolit, kaki - wajib ke GND. Pasang dekat beban terkait.

1

100 nF bypass LED1

Kapasitor bypass/filter; untuk elektrolit, kaki - wajib ke GND. Pasang dekat beban terkait.

1

100 nF bypass LED2

Kapasitor bypass/filter; untuk elektrolit, kaki - wajib ke GND. Pasang dekat beban terkait.

1

100 nF bypass mic kiri

Kapasitor bypass/filter; untuk elektrolit, kaki - wajib ke GND. Pasang dekat beban terkait.

1

100 nF bypass mic kanan

Kapasitor bypass/filter; untuk elektrolit, kaki - wajib ke GND. Pasang dekat beban terkait.

1

10 uF 10V bypass layar

Kapasitor bypass/filter; untuk elektrolit, kaki - wajib ke GND. Pasang dekat beban terkait.

1

100 uF 10V elektrolit input boost

Kapasitor bypass/filter; untuk elektrolit, kaki - wajib ke GND. Pasang dekat beban terkait.

1

1000 uF 10V elektrolit rail 5V

Kapasitor bypass/filter; untuk elektrolit, kaki - wajib ke GND. Pasang dekat beban terkait.

1

100 uF 10V elektrolit dekat amplifier

Kapasitor bypass/filter; untuk elektrolit, kaki - wajib ke GND. Pasang dekat beban terkait.

Assemble it in 9 steps

1. Kenali modul dan putus semua daya

Gunakan DevKitC-1 dengan modul ESP32-S3-WROOM-1-N8R8, layar breakout Waveshare 1.69inch 8-pin, dua INMP441 dan breakout MAX98357A. Ikuti label pin, bukan warna kabel. GPIO35/36/37 dipakai PSRAM, GPIO19/20 untuk USB dan pin boot tidak dipakai.

  • Rangkaian ini bukan untuk modul ESP32 telanjang atau panel layar FPC. Modul serupa dengan pinout berbeda tidak boleh disambung berdasarkan urutan fisik yang diasumsikan.

2. Siapkan baterai terlindungi dan sensor suhu

Gunakan satu protected 18650 3000mAh dengan proteksi terintegrasi, rating discharge minimal 3A dan charge minimal 1A. Pasang pada holder yang sesuai panjangnya. P+ ke LIPO charger, P- ke GND. Cocokkan polaritas JST dengan multimeter. Cut jumper THERM charger lalu pasang NTC 10k antara THERM dan GND. Tempel NTC terisolasi listrik ke badan sel. Pertahankan jumper arus pengisian default 1A.

  • Jangan gunakan sel polos, sel rusak, sel bekas tak dikenal, LiFePO4, atau 2 sel seri. BQ24074 bukan pengganti PCB proteksi sel. Polaritas terbalik/korslet dapat menyebabkan kebakaran. Jangan menyolder langsung ke sel; jangan mengisi tanpa pengawasan.

3. Rangkai jalur daya dan saklar OFF

OUT charger ke VIN Pololu U3V70F5. GND charger dan boost ke ground bersama. VOUT boost ke rail 5V dan pin 5V DevKit. Regulator bawaan DevKit menghasilkan rail 3.3V. Saklar OFF menghubungkan ENABLE boost ke GND saat tertutup; buka untuk ON. Kapasitor input 100uF di OUT charger, bulk 1000uF di rail 5V. Semua kaki negatif kapasitor elektrolit ke GND. USB-C adaptor 5V 3A masuk ke charger, bukan rail 3.3V.

  • Ukur rail 5V dan 3.3V sebelum memasang periferal. Gunakan kabel daya pendek 22AWG, sambungan solder/terminal, bukan breadboard untuk jalur baterai dan amplifier.
  • OUT charger maksimal 1.5A. Anggaran rail 5V sekitar 0.75A kontinu saat baterai rendah. Firmware sengaja membatasi volume serta kecerahan; speaker 3W adalah rating, bukan target daya kontinu.
  • Jangan memasukkan output boost ke pin 3V3. Jangan hubungkan daya boost ke DevKit bersamaan dengan kabel USB biasa: dapat memberi arus balik ke komputer. Untuk Deploy lepaskan kabel boost ke pin 5V DevKit dan matikan boost; gunakan daya USB saja. Pengujian daya baterai dilakukan setelah kabel USB DevKit dilepas.

4. Hubungkan layar landscape

VCC layar ke 3V3, GND ke GND; CLK ke GPIO12 (clock), DIN ke GPIO11 (data gambar), CS ke GPIO10 (pilih layar), DC ke GPIO9 (data/perintah), RST ke GPIO8 (reset), BL ke GPIO7 (kontrol backlight modul). Pasang kapasitor 10uF dekat VCC/GND layar. Gunakan breakout Waveshare yang memiliki rangkaian backlight, bukan LED panel langsung.

  • Firmware memakai 240x280, rotasi 1 menjadi 280x240. Seluruh sinyal logika layar adalah 3.3V.

5. Hubungkan dua mikrofon sebagai stereo

Kedua VDD ke 3V3 dan kedua GND ke GND. Kedua SCK ke GPIO4 (bit clock), kedua WS ke GPIO5 (pilih kanal), dan kedua SD ke GPIO6 (data bersama). L/R mic kiri ke GND dan L/R mic kanan ke 3V3. Resistor 100k dari SD bersama ke GND. Pasang bypass 100nF pada masing-masing mikrofon.

  • INMP441 tidak boleh diberi 5V. Dua SD boleh digabung hanya karena L/R memilih slot stereo yang berbeda. Jangan mengikat kedua L/R ke level yang sama.

6. Hubungkan amplifier dan speaker

VIN MAX98357A ke 5V, GND ke GND. BCLK ke GPIO15, LRC ke GPIO16, DIN ke GPIO17. Speaker hanya antara SPK+ dan SPK-. SD dan GAIN breakout dibiarkan pada default bawaan. Kapasitor 100uF dekat VIN/GND amp. Jauhkan mikrofon dari speaker dan boost.

  • SPK- bukan ground! Jangan sambungkan terminal speaker ke GND atau ke input audio komputer. Speaker dan amplifier jangan diuji pada volume maksimum dengan jalur daya ini.

7. Pasang penggeser level dan dua LED

SN74AHCT125N PDIP-14: kaki14 ke 5V, kaki7 ke GND, kaki1 /OE ke GND, kaki2 input ke GPIO21, kaki3 output melalui resistor 330ohm ke DIN LED1. DOUT LED1 melalui resistor 330ohm kedua ke DIN LED2. DOUT LED2 kosong. Kedua VCC LED ke 5V, GND ke GND. Input tak terpakai kaki5/9/12 ke GND; /OE kaki4/10/13 ke 5V; output6/8/11 kosong. Pulldown100k dari kaki2 ke GND. Bypass100nF di IC dan setiap pixel.

  • Pastikan IC AHCT, bukan HC yang ambang inputnya tidak cocok. LED tersusun berantai DATA, bukan seri pada jalur daya. Pembatas arus RGB berada dalam WS2812B; resistor eksternal adalah pelindung jalur data.

8. Pasang tombol dan pengukur baterai

Tombol normally-open antara GPIO18 dan GND; pull-up internal aktif. P+ baterai ke resistor atas100k, ujung lainnya ke GPIO1. Resistor bawah100k dari GPIO1 ke GND. Kapasitor100nF dari GPIO1 ke GND. Pada baterai4.2V, ADC menerima2.1V. Jangan pernah menyambung baterai langsung ke GPIO.

  • Kalibrasikan tegangan terhadap multimeter; pembacaan tegangan bukan pengukur persentase kapasitas. Batas3.25V firmware adalah peringatan, proteksi hardware dalam sel tetap wajib.

9. Periksa dan lakukan uji awal bertahap

Periksa korslet rail terhadap GND, polaritas baterai dan kapasitor, serta semua ground bersama. Untuk uji USB: matikan boost dan lepaskan boost dari pin5V DevKit, lalu gunakan USB DevKit saja. Periksa wajah dan kedua LED dahulu. Tekan PTT sambil berbicara singkat lalu lepaskan: rekam stereo0.5s, THINKING1s, kemudian putar ulang pelan. Saat pengujian baterai, lepas USB DevKit sebelum menyambung kembali boost ke pin5V.

  • Semua periferal tetap mendapat rail dari pin5V/3V3 DevKit saat uji USB. Gunakan port USB dengan kemampuan arus cukup, volume rendah dan LED redup.
  • Periksa kenaikan suhu charger/boost saat mengisi sambil menyala. Sisakan ventilasi dan jauhkan sel dari komponen panas. Jika panas berlebihan, matikan dan periksa rangkaian.

Review all connections

1. Connections between "lcd" and "ESP32"

FunctionlcdESP32
spiCLKGPIO 12
spiDINGPIO 11
spiCSGPIO 10
digitalDCGPIO 9
digitalRSTGPIO 8
digitalBLGPIO 7
powerVCC3V3
groundGNDGND

2. Connections between "mic_l" and "ESP32"

Functionmic_lESP32
powerVDD3V3
groundGNDGND
digitalSCKGPIO 4
digitalWSGPIO 5
dataSDGPIO 6
groundL/RGND

3. Connections between "mic_r" and "ESP32"

Functionmic_rESP32
powerVDD3V3
groundGNDGND
digitalSCK → HiLetgo INMP441 I²S Microphone Module SCKEXT
digitalWS → HiLetgo INMP441 I²S Microphone Module WSEXT
dataSD → HiLetgo INMP441 I²S Microphone Module SDEXT
powerL/R3V3

4. Connections between "amp" and "ESP32"

FunctionampESP32
powerVIN5V
groundGNDGND
dataBCLKGPIO 15
dataLRCGPIO 16
dataDINGPIO 17
dataSPK+ → Speaker 4 ohm 3 W +EXT
dataSPK- → Speaker 4 ohm 3 W -EXT

5. Connections between "led1" and "ESP32"

Functionled1ESP32
powerVCC5V
groundGNDGND
dataDOUT → Resistor 330 ohm seri data 2 1EXT

6. Connections between "led2" and "ESP32"

Functionled2ESP32
powerVCC5V
groundGNDGND

7. Connections between "r_data2" and "ESP32"

Functionr_data2ESP32
data2 → WS2812B pixel breakout 5 V DINEXT

8. Connections between "level" and "ESP32"

FunctionlevelESP32
powerVCC145V
groundGND7GND
groundOE1GND
digitalA2GPIO 21
digitalY3 → Resistor 330 ohm seri data 1 1EXT
powerOE45V
groundA5GND
powerOE105V
groundA9GND
powerOE135V
groundA12GND

9. Connections between "r_data1" and "ESP32"

Functionr_data1ESP32
data2 → WS2812B pixel breakout 5 V DINEXT

10. Connections between "r_ledpull" and "ESP32"

Functionr_ledpullESP32
data1 → SN74AHCT125N PDIP-14 level shifter A2EXT
ground2GND

11. Connections between "ptt" and "ESP32"

FunctionpttESP32
dataAGPIO 18
groundBGND

12. Connections between "r_sd" and "ESP32"

Functionr_sdESP32
data1 → HiLetgo INMP441 I²S Microphone Module SDEXT
ground2GND

13. Connections between "battery" and "ESP32"

FunctionbatteryESP32
powerP+ → Adafruit BQ24074 charger #4755 LIPOEXT
groundP-GND
powerP+ → 100 kohm 1% pembagi atas 1EXT

14. Connections between "charger" and "ESP32"

FunctionchargerESP32
groundGNDGND
powerOUT → Pololu U3V70F5 #2891 boost 5 V VINEXT

15. Connections between "boost" and "ESP32"

FunctionboostESP32
groundGNDGND
powerVOUT5V
digitalENABLE → Saklar OFF boost, SPST AEXT

16. Connections between "off" and "ESP32"

FunctionoffESP32
groundBGND

17. Connections between "ntc" and "ESP32"

FunctionntcESP32
data1 → Adafruit BQ24074 charger #4755 THERMEXT
ground2GND

18. Connections between "r_batt_top" and "ESP32"

Functionr_batt_topESP32
data2GPIO 1

19. Connections between "r_batt_bottom" and "ESP32"

Functionr_batt_bottomESP32
data1 → 100 kohm 1% pembagi atas 2EXT
ground2GND

20. Connections between "c_adc" and "ESP32"

Functionc_adcESP32
data+ → 100 kohm 1% pembagi atas 2EXT
ground-GND

21. Connections between "c_level" and "ESP32"

Functionc_levelESP32
power+5V
ground-GND

22. Connections between "c_led1" and "ESP32"

Functionc_led1ESP32
power+5V
ground-GND

23. Connections between "c_led2" and "ESP32"

Functionc_led2ESP32
power+5V
ground-GND

24. Connections between "c_micl" and "ESP32"

Functionc_miclESP32
power+3V3
ground-GND

25. Connections between "c_micr" and "ESP32"

Functionc_micrESP32
power+3V3
ground-GND

26. Connections between "c_lcd" and "ESP32"

Functionc_lcdESP32
power+3V3
ground-GND

27. Connections between "c_input" and "ESP32"

Functionc_inputESP32
data+ → Adafruit BQ24074 charger #4755 OUTEXT
ground-GND

28. Connections between "c_bulk" and "ESP32"

Functionc_bulkESP32
power+5V
ground-GND

29. Connections between "c_amp" and "ESP32"

Functionc_ampESP32
power+5V
ground-GND

Deploy the firmware

#include <Arduino.h>
#include <SPI.h>
#include <WiFi.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ST7789.h>
#include <Adafruit_NeoPixel.h>
#include <driver/i2s.h>
#include <esp_heap_caps.h>
#include <math.h>
#include "config.h"

// Seluruh GPIO sesuai wiring.yaml; LED bawaan DevKit tidak digunakan.
constexpr int TFT_SCK=12, TFT_MOSI=11, TFT_CS=10, TFT_DC=9, TFT_RST=8, TFT_BL=7;
constexpr int MIC_BCLK=4, MIC_WS=5, MIC_SD=6;
constexpr int AMP_BCLK=15, AMP_WS=16, AMP_DIN=17;
constexpr int PTT_PIN=18, RGB_PIN=21, BATTERY_PIN=1;
constexpr uint32_t SAMPLE_RATE=16000;
constexpr size_t CAPTURE_FRAMES=8000; // 0.5 detik stereo, RAM internal 32 kB
constexpr uint16_t CYAN=0x07FF, BLACK=0x0000;
enum RobotState { IDLE, LISTENING, THINKING, SPEAKING, ERROR };
enum Expression { NEUTRAL, HAPPY, SAD, ANGRY, SURPRISED, PONDER };
RobotState state=IDLE;
Expression expression=NEUTRAL;
const char* stateNames[]={"IDLE","LISTENING","THINKING","SPEAKING","ERROR"};
Adafruit_ST7789 display(&SPI,TFT_CS,TFT_DC,TFT_RST);
Adafruit_NeoPixel pixels(2,RGB_PIN,NEO_GRB+NEO_KHZ800);
GFXcanvas16 face(280,170); // frame offscreen agar gambar tidak berkedip hitam
int16_t* captured=nullptr; // interleaved L,R signed PCM16
int32_t rxWords[256];
int16_t txPCM[256];
size_t capturedFrames=0, playedFrames=0;
bool micReady=false, ampReady=false, recording=false, captureFull=false;
bool dirtyStatus=true, dirtyFace=true, batteryLow=false;
bool wifiPending=false, wifiWasConnected=false;
uint32_t stateSince=0, lastFrame=0, lastMeter=0, lastBattery=0, wifiSince=0;
uint32_t blinkStart=0, nextBlink=2200;
float batteryVolts=0;
int peakL=0,peakR=0;
String fault;
Expression demoOverride=NEUTRAL;
bool autoDemo=true, testTone=false;
uint32_t toneSample=0;

// Titik integrasi nyata: tidak ada HTTP/API palsu dan tidak ada kunci tersimpan.
// Future: unggah PCM16 stereo/mono atau WAV melalui HTTPS dengan verifikasi CA.
String sttPlaceholder(const int16_t* pcm,size_t frames) {
  (void)pcm;(void)frames;
  Serial.println("[STT placeholder] Tidak mengirim audio; mode uji lokal.");
  return "";
}
String llmPlaceholder(const String& text) {(void)text; return "";}
bool ttsPlaceholder(const String& text) {(void)text; return false;}

void setState(RobotState value) {
  state=value;stateSince=millis();dirtyStatus=dirtyFace=true;
  uint32_t c=0;
  switch(state) {
    case IDLE: c=pixels.Color(0,22,25);break;
    case LISTENING:c=pixels.Color(0,45,5);break;
    case THINKING:c=pixels.Color(30,0,40);break;
    case SPEAKING:c=pixels.Color(5,10,50);break;
    case ERROR:c=pixels.Color(55,0,0);break;
  }
  for(int i=0;i<2;i++) pixels.setPixelColor(i,c);
  pixels.show();
  Serial.printf("State: %s\n",stateNames[state]);
}
void fail(const char* message) {
  fault=message; recording=false;setState(ERROR);Serial.println(message);
}

bool setupI2S() {
  i2s_config_t rx={};
  rx.mode=(i2s_mode_t)(I2S_MODE_MASTER|I2S_MODE_RX);
  rx.sample_rate=SAMPLE_RATE;
  rx.bits_per_sample=I2S_BITS_PER_SAMPLE_32BIT;
  rx.channel_format=I2S_CHANNEL_FMT_RIGHT_LEFT;
  rx.communication_format=I2S_COMM_FORMAT_STAND_I2S;
  rx.intr_alloc_flags=ESP_INTR_FLAG_LEVEL1;
  rx.dma_buf_count=8;rx.dma_buf_len=128;
  rx.use_apll=false;rx.tx_desc_auto_clear=false;rx.fixed_mclk=0;
  i2s_pin_config_t rp={};
  rp.mck_io_num=I2S_PIN_NO_CHANGE;rp.bck_io_num=MIC_BCLK;
  rp.ws_io_num=MIC_WS;rp.data_out_num=I2S_PIN_NO_CHANGE;rp.data_in_num=MIC_SD;
  if(i2s_driver_install(I2S_NUM_0,&rx,0,nullptr)!=ESP_OK) return false;
  if(i2s_set_pin(I2S_NUM_0,&rp)!=ESP_OK) {i2s_driver_uninstall(I2S_NUM_0);return false;}
  micReady=true;
  i2s_config_t tx={};
  tx.mode=(i2s_mode_t)(I2S_MODE_MASTER|I2S_MODE_TX);
  tx.sample_rate=SAMPLE_RATE;tx.bits_per_sample=I2S_BITS_PER_SAMPLE_16BIT;
  tx.channel_format=I2S_CHANNEL_FMT_RIGHT_LEFT;
  tx.communication_format=I2S_COMM_FORMAT_STAND_I2S;
  tx.intr_alloc_flags=ESP_INTR_FLAG_LEVEL1;
  tx.dma_buf_count=8;tx.dma_buf_len=128;
  tx.use_apll=false;tx.tx_desc_auto_clear=true;tx.fixed_mclk=0;
  i2s_pin_config_t tp={};tp.mck_io_num=I2S_PIN_NO_CHANGE;
  tp.bck_io_num=AMP_BCLK;tp.ws_io_num=AMP_WS;tp.data_out_num=AMP_DIN;
  tp.data_in_num=I2S_PIN_NO_CHANGE;
  if(i2s_driver_install(I2S_NUM_1,&tx,0,nullptr)!=ESP_OK)return false;
  if(i2s_set_pin(I2S_NUM_1,&tp)!=ESP_OK){i2s_driver_uninstall(I2S_NUM_1);return false;}
  ampReady=true;i2s_zero_dma_buffer(I2S_NUM_1);return true;
}

void serviceMicrophones() {
  if(!micReady)return;
  // Drain DMA without waiting. Standard I2S: 24-bit mic sample in MSBs of 32-bit slot.
  // ESP32 RX DMA RIGHT_LEFT places WS=0 (left) then WS=1 (right) per frame.
  for(int block=0;block<3;block++) {
    size_t got=0;
    esp_err_t e=i2s_read(I2S_NUM_0,rxWords,sizeof(rxWords),&got,0);
    if(e!=ESP_OK && e!=ESP_ERR_TIMEOUT){fail("I2S RX gagal");return;}
    if(!got)break;
    size_t frames=got/(2*sizeof(int32_t));
    for(size_t i=0;i<frames;i++) {
      int16_t l=(int16_t)(rxWords[2*i]>>16);
      int16_t r=(int16_t)(rxWords[2*i+1]>>16);
      peakL=max(peakL,abs((int)l));peakR=max(peakR,abs((int)r));
      if(recording && capturedFrames<CAPTURE_FRAMES) {
        captured[capturedFrames*2]=l;captured[capturedFrames*2+1]=r;
        if(++capturedFrames==CAPTURE_FRAMES) {captureFull=true;dirtyStatus=true;}
      }
    }
  }
}

void servicePlayback() {
  if(state!=SPEAKING || !ampReady)return;
  // Chunk kecil, tanpa menunggu DMA, sehingga tombol dan wajah tetap responsif.
  size_t remain=testTone? (SAMPLE_RATE/4-toneSample):(capturedFrames-playedFrames);
  if(!remain) {
    // Allow the last DMA chunk to finish; do not cut the speaker tail.
    if(millis()-stateSince>750)setState(IDLE);
    return;
  }
  size_t n=min(remain,(size_t)128);
  for(size_t i=0;i<n;i++) {
    int mono;
    if(testTone) {
      float env=sinf(PI*(toneSample+i)/(SAMPLE_RATE/4));
      mono=(int)(700*env*sinf(2*PI*660*(toneSample+i)/SAMPLE_RATE));
    } else {
      mono=((int)captured[(playedFrames+i)*2]+captured[(playedFrames+i)*2+1])/2;
      mono=constrain(mono/2,-2000,2000); // low-volume commissioning, protect power budget
    }
    txPCM[2*i]=txPCM[2*i+1]=(int16_t)mono;
  }
  size_t written=0;
  esp_err_t e=i2s_write(I2S_NUM_1,txPCM,n*2*sizeof(int16_t),&written,0);
  if(e!=ESP_OK && e!=ESP_ERR_TIMEOUT){fail("I2S TX gagal");return;}
  size_t count=written/(2*sizeof(int16_t));
  if(testTone)toneSample+=count;else playedFrames+=count;
}

void startListening() {
  if(state==ERROR || !captured || !micReady || batteryLow)return;
  if(state==SPEAKING)i2s_zero_dma_buffer(I2S_NUM_1);
  capturedFrames=0;playedFrames=0;captureFull=false;recording=true;testTone=false;
  setState(LISTENING);
}
void stopListening() {
  if(state!=LISTENING)return;
  recording=false;
  Serial.printf("Captured %u frames, stereo 16k PCM16, %.2f s\n",(unsigned)capturedFrames,capturedFrames/(float)SAMPLE_RATE);
  setState(THINKING);
  String text=sttPlaceholder(captured,capturedFrames);
  String answer=llmPlaceholder(text);ttsPlaceholder(answer);
}
void buttonTick() {
  static bool raw=false, stable=false;
  static uint32_t changed=0;
  bool p=digitalRead(PTT_PIN)==LOW;
  if(p!=raw){raw=p;changed=millis();}
  if(p!=stable && millis()-changed>=25) {
    stable=p;
    if(stable)startListening();else stopListening();
  }
}

void readBattery() {
  uint32_t sum=0;
  for(int i=0;i<16;i++)sum+=analogReadMilliVolts(BATTERY_PIN);
  batteryVolts=(sum/16.0f)*0.002f*BATTERY_CALIBRATION;
  // Divider 100k/100k: maximum 4.2V battery -> 2.1V ADC. Not a fuel gauge.
  batteryLow=batteryVolts>1.0f && batteryVolts<3.25f;
  if(batteryLow && state!=ERROR)fail("Baterai rendah: OFF & isi ulang");
  dirtyStatus=true;
}

void drawStatus() {
  if(!dirtyStatus)return;
  dirtyStatus=false;
  display.fillRect(12,12,256,26,BLACK);
  display.setTextSize(2);display.setTextColor(CYAN,BLACK);display.setCursor(96,16);display.print("MIMO V1");
  display.fillRect(12,207,256,30,BLACK);
  display.setTextSize(1);display.setCursor(20,209);display.print(stateNames[state]);
  display.setCursor(156,209);display.printf("BAT %.2fV",batteryVolts);
  display.setCursor(20,224);
  if(state==ERROR)display.print(fault.substring(0,38));
  else if(state==LISTENING && captureFull) display.print("Buffer penuh - lepas tombol");
  else if(state==LISTENING)display.print("Merekam stereo (maks 0.5s)");
  else display.print(WiFi.status()==WL_CONNECTED?"Wi-Fi OK | AI belum terhubung":"Offline | uji lokal siap");
}

void drawFace() {
  uint32_t now=millis();
  if(now-lastFrame<33)return;
  lastFrame=now;
  Expression next=NEUTRAL;
  if(state==LISTENING)next=SURPRISED;
  else if(state==THINKING)next=PONDER;
  else if(state==SPEAKING)next=HAPPY;
  else if(state==ERROR)next=SAD;
  else next=autoDemo?(Expression)((now/4000)%6):demoOverride;
  if(expression!=next){expression=next;dirtyFace=true;}
  if(!blinkStart && (int32_t)(now-nextBlink)>=0)blinkStart=now;
  float open=1;
  if(blinkStart) {
    float t=(now-blinkStart)/240.0f;
    if(t>=1){blinkStart=0;nextBlink=now+random(2200,5200);dirtyFace=true;}
    else {open=1-sinf(PI*t);dirtyFace=true;}
  }
  int gaze=expression==PONDER?(int)(sinf(now/600.0f)*11):0;
  int mouth=state==SPEAKING? (int)(3+6*(1+sinf(now/75.0f))):4;
  static int prevGaze=-99, prevMouth=-99,prevEye=-99;
  int eyeH=max(3,(int)(56*open));
  if(gaze!=prevGaze||mouth!=prevMouth||eyeH!=prevEye)dirtyFace=true;
  if(!dirtyFace)return;
  dirtyFace=false;prevGaze=gaze;prevMouth=mouth;prevEye=eyeH;
  face.fillScreen(BLACK);
  for(int eye=0;eye<2;eye++) {
    int x=(eye?170:48)+gaze, y=48+(56-eyeH)/2;
    if(expression==SURPRISED) {
      face.fillRoundRect(x,y,56,eyeH,min(16,eyeH/2),CYAN);
      if(eyeH>18)face.fillRoundRect(x+13,y+7,30,eyeH-14,8,BLACK);
    } else {
      face.fillRoundRect(x,y,62,eyeH,min(15,eyeH/2),CYAN);
      if(expression==ANGRY && eyeH>12) {
        if(!eye)face.fillTriangle(x,y,x+62,y,x+62,y+23,BLACK);
        else face.fillTriangle(x,y,x+62,y,x,y+23,BLACK);
      }
      if(expression==SAD && eyeH>12) {
        if(!eye)face.fillTriangle(x,y,x+62,y,x,y+21,BLACK);
        else face.fillTriangle(x,y,x+62,y,x+62,y+21,BLACK);
      }
      if(expression==HAPPY && eyeH>18)face.fillRoundRect(x-2,y+eyeH/2,66,eyeH,16,BLACK);
    }
  }
  if(expression==SURPRISED)face.fillRoundRect(128,123,24,24,10,CYAN);
  else if(state==SPEAKING)face.fillRoundRect(116,134-mouth/2,48,mouth,4,CYAN);
  else if(expression==PONDER) {
    face.fillRoundRect(126,134,30,5,2,CYAN);
    int dot=(now/250)%3;
    face.fillCircle(216+dot*12,122,3,CYAN);
    static int prevDot=-1;if(prevDot!=dot){prevDot=dot;dirtyFace=true;}
  } else {
    for(int dx=-24;dx<=24;dx++) {
      int curve=(576-dx*dx)/90;
      int y=134+((expression==SAD||expression==ANGRY)?-curve:curve);
      face.drawFastVLine(140+dx,y,4,CYAN);
    }
  }
  display.drawRGBBitmap(0,38,face.getBuffer(),280,170);
}

void serviceWiFi() {
  bool connected=WiFi.status()==WL_CONNECTED;
  if(connected!=wifiWasConnected) {
    wifiWasConnected=connected;dirtyStatus=true;
    if(connected){wifiPending=false;Serial.print("Wi-Fi IP: ");Serial.println(WiFi.localIP());}
  }
  if(wifiPending && millis()-wifiSince>15000) {
    wifiPending=false;WiFi.disconnect();Serial.println("Wi-Fi timeout; lanjut uji offline.");dirtyStatus=true;
  }
}
void serialTick() {
  if(!Serial.available())return;
  char c=Serial.read();
  if(c>='0'&&c<='5'){autoDemo=false;demoOverride=(Expression)(c-'0');dirtyFace=true;}
  if(c=='d'){autoDemo=true;dirtyFace=true;}
  if(c=='t' && state==IDLE && ampReady && !batteryLow) {
    testTone=true;toneSample=0;setState(SPEAKING);
  }
  if(c=='e' && state==IDLE)fail("Demo ERROR; tekan r untuk pulih");
  if(c=='r' && state==ERROR && !batteryLow && micReady && ampReady && captured){fault="";setState(IDLE);}
}
void setup() {
  Serial.begin(115200);delay(200);
  pinMode(PTT_PIN,INPUT_PULLUP);
  pinMode(TFT_BL,OUTPUT);digitalWrite(TFT_BL,LOW);
  pixels.begin();pixels.setBrightness(64);pixels.clear();pixels.show();
  SPI.begin(TFT_SCK,-1,TFT_MOSI,TFT_CS);
  display.init(240,280,SPI_MODE0);display.setRotation(1);display.setSPISpeed(27000000);
  display.fillScreen(BLACK);digitalWrite(TFT_BL,HIGH);
  analogReadResolution(12);analogSetPinAttenuation(BATTERY_PIN,ADC_11db);
  captured=(int16_t*)heap_caps_malloc(CAPTURE_FRAMES*2*sizeof(int16_t),MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT);
  bool audio=setupI2S();
  setState(IDLE);
  if(!captured || !face.getBuffer())fail("RAM tidak cukup");
  else if(!audio)fail("Inisialisasi audio gagal");
  readBattery();
  WiFi.mode(WIFI_STA);
  if(WIFI_SSID[0]) {
    WiFi.setAutoReconnect(true);WiFi.begin(WIFI_SSID,WIFI_PASSWORD);wifiSince=millis();wifiPending=true;
  } else {WiFi.mode(WIFI_OFF);Serial.println("SSID kosong: mode offline.");}
  Serial.printf("MIMO V1 | flash %u | PSRAM %u | heap %u\n",ESP.getFlashChipSize(),ESP.getPsramSize(),ESP.getFreeHeap());
  Serial.println("PTT tahan/bicara/lepas: rekam 0.5s -> THINKING -> putar ulang pelan.");
  Serial.println("Serial: 0 netral, 1 happy, 2 sad, 3 angry, 4 surprised, 5 thinking; d demo; t nada; e error; r pulih.");
}
void loop() {
  buttonTick();serviceMicrophones();servicePlayback();serviceWiFi();serialTick();
  if(state==THINKING && millis()-stateSince>=1000) {
    if(!capturedFrames)setState(IDLE);
    else {testTone=false;playedFrames=0;setState(SPEAKING);}
  }
  if(millis()-lastBattery>=5000){lastBattery=millis();readBattery();}
  if(millis()-lastMeter>=1000) {
    lastMeter=millis();Serial.printf("MIC peak L=%d R=%d | BAT=%.2fV | heap=%u\n",peakL,peakR,batteryVolts,ESP.getFreeHeap());
    peakL=peakR=0;
  }
  if(face.getBuffer()){drawFace();drawStatus();}
  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