Hardware

Reverse-Engineering a No-Name ESP32 Board

I bought the cheapest "AI voice module" on Amazon, found out which of its pins did what without a schematic, and made it hold a spoken conversation with the voice AI we build at work. Most of the afternoon went on one button.

Prateek Gupta · Sum Vivas, Manchester · April 2026 · code

£20.43
what I paid
0
pages of documentation
13
pins confirmed
1
button that lies

My day job at Sum Vivas is digital humans, and the bit I spend most time on is the voice. We have a service called Thalamus that holds a live spoken conversation over a WebSocket: you stream audio in, it streams audio back, and in between it tells you what it heard and what it said. So far everything that talks to it has been a browser or a phone. I wanted something with no screen at all. A box with a button. Hold the button, say something, let go, listen.

The quickest way to get there was to buy a board that already had a microphone, a speaker and Wi-Fi on it, and Amazon had one for £20.43. The seller was called "Aolidsive". The model name was a keyboard mash. The description had clearly been through a translator twice. I bought it anyway, because the parts list was actually good.

the Amazon listing
The listing, more or less. This one is a different "brand" at £14.34; mine was "Aolidsive" at £20.43. It is the same board. The word XiaoZhi in the title turns out to matter later.

What arrived

A PCB in a plain bag, a small speaker on red wires, and a USB-C cable. On the board:

  • an ESP32-S3 module with Wi-Fi and Bluetooth
  • an INMP441 I2S microphone, the tiny silver thing in the corner
  • a MAX98357 I2S amplifier, with a JST plug for the speaker
  • a CH340X USB-to-serial chip, so it programs over the USB-C port
  • a TP5400 charger with a JST plug for a battery and four LEDs to show the level
  • five buttons labelled EN, BOOT, volume−, volume+ and WiFi reset, and a power switch
  • a three-pin header on the back marked PIR, for a motion sensor

No schematic. No SDK. No link to a repo. The silkscreen gives you the button names and nothing else. Flip it over and there is a part number, ESP32S3-AI V2.2 (303ESP32AI2), which brings up nothing useful anywhere. That is the whole manual.

the board, front
Front. Module in the middle, mic top left, the five buttons down the right edge, USB-C and the speaker and battery plugs on the left.
the board, back
Back. The PIR header, the battery and speaker labels, and the only part number you get.

Is anyone home

First job, see if Windows can see it. The CH340X should show up as a COM port, and it did.

Get-WmiObject Win32_PnPEntity | Where-Object { $_.Name -match 'CH340' }

Name     : USB-SERIAL CH340 (COM3)
Status   : OK

Then esptool, which will tell you what the chip is if you ask nicely.

esptool --port COM3 chip-id

Chip type:          ESP32-S3 (QFN56) (revision v0.2)
Features:           Wi-Fi, BT 5 (LE), Dual Core + LP Core, 240MHz, Embedded PSRAM 8MB (AP_3v3)
Crystal frequency:  40MHz

An ESP32-S3 with 8 MB of PSRAM. The module marking, S3-N16R8, means 16 MB of flash and 8 MB of PSRAM. For a device whose entire job is to move audio in two directions, that is a lot of room.

Which pin does what

With no schematic there were two ways to find the pins. One was to trace the board with a multimeter. The other was to notice the word XiaoZhi in the Amazon listing and go and look.

Xiaozhi is a big open-source Chinese ESP32 voice-assistant project, tens of thousands of stars and a long list of supported boards. This board is obviously made to sell into that ecosystem, and its bread-compact-wifi configuration uses the same microphone and amplifier. A board built to run someone else's firmware has every reason to copy their pinout.

// xiaozhi-esp32/main/boards/bread-compact-wifi/config.h
#define AUDIO_I2S_MIC_GPIO_WS   GPIO_NUM_4
#define AUDIO_I2S_MIC_GPIO_SCK  GPIO_NUM_5
#define AUDIO_I2S_MIC_GPIO_DIN  GPIO_NUM_6
#define AUDIO_I2S_SPK_GPIO_DOUT GPIO_NUM_7
#define AUDIO_I2S_SPK_GPIO_BCLK GPIO_NUM_15
#define AUDIO_I2S_SPK_GPIO_LRCK GPIO_NUM_16
#define BOOT_BUTTON_GPIO        GPIO_NUM_0
#define VOLUME_UP_BUTTON_GPIO   GPIO_NUM_40
#define VOLUME_DOWN_BUTTON_GPIO GPIO_NUM_39
#define BUILTIN_LED_GPIO        GPIO_NUM_48

That is a guess, not a pin map, so I wrote a test firmware to check every line of it. A PlatformIO project, the Arduino framework, and a serial menu with four tests: watch the buttons for ten seconds, play tones through the speaker, read the mic and print a level meter, and pipe the mic straight into the speaker. A short Python script drives the menu over the serial port.

Table 1. What the test firmware found.
PartResult
ESP32-S3 at 240 MHzfine
PSRAM, 8 MB8,386,279 bytes, after one fix
Speaker, MAX98357440, 880 and 1760 Hz, then a sweep, all clean
Microphone, INMP441audio, but a peak of 841 out of 32,767
Loopbackmy voice out of the speaker, quietly
volume− on GPIO39, volume+ on GPIO40confirmed, active low
BOOT on GPIO0confirmed, with a catch
WiFi resetreboots the chip. It is wired to EN, not to a GPIO at all

Every borrowed pin was right. Two lines in that table come back to bite later: the mic is very quiet, and the WiFi reset button is not a button you can read.

The PSRAM one

First boot said this and carried on with no PSRAM at all:

E (89) psram: PSRAM ID read error: 0x00ffffff

The Arduino framework assumes quad-SPI PSRAM. This module has octal. One line in platformio.ini fixes it:

board_build.arduino.memory_type = qio_opi

After that, PSRAM: 8386279 bytes. All of it.

Talking to Thalamus

The /chat/live endpoint is a WebSocket. You send raw 16-bit PCM at 16 kHz, mono, as binary frames. It sends back raw 16-bit PCM at 24 kHz, mono, also as binary frames, with JSON text frames in between for events: session_started, transcript, turn_complete, interrupted, error.

The ESP32-S3 has two I2S peripherals, which suits this exactly. One runs the mic at 16 kHz, the rate the server listens at. The other runs the speaker at 24 kHz, the rate the server talks at. No resampling either way.

It also has two cores, and I used both.

Core 1, main loop
  read the push-to-talk button
  while held: read 20 ms of mic audio, apply gain, send it as a binary frame
  service the WebSocket
    binary frame  -> ring buffer in PSRAM
    text frame    -> parse the JSON, log the event

Core 0, pinned tasks
  playback: ring buffer -> volume -> I2S write, forever
  LED: a blink pattern per state (connecting, idle, recording, playing, error)

Playback gets its own core because an I2S write blocks until the DMA buffer has room. Share that core with a TLS WebSocket client and the audio stutters every time the network does anything. The ring buffer is one second of 24 kHz audio, 48,000 bytes, in PSRAM so the main heap is left alone for TLS.

Two libraries did the heavy lifting, links2004/WebSockets for the secure WebSocket and bblanchon/ArduinoJson for the events. The rest is WiFi.h and the I2S driver.

Three things that went wrong

1. The button that lies

BOOT on GPIO0 is the biggest button on the board, so it became push-to-talk. The firmware then acted as if it was being held down permanently. Recording started at boot and never stopped.

This is the CH340X. USB-serial chips on ESP32 boards use GPIO0 as a strapping pin, so a flashing tool can drop the chip into its bootloader, and they drive it from the serial port's DTR and RTS lines. Open the port with DTR asserted, which nearly every serial monitor does by default, and the chip holds GPIO0 low. Low is exactly what a pressed button looks like.

My first fix was to move push-to-talk to the WiFi reset button, which I had assumed was GPIO47. Pressing it rebooted the board. That button goes straight to EN, the hardware reset, and no firmware in the world can read it. So the fix that stuck is the boring one: keep GPIO0, and open the serial port properly.

# monitor.py
ser = serial.Serial("COM3", 115200, timeout=1, dsrdtr=False, rtscts=False)
ser.dtr = False          # leave GPIO0 alone
ser.rts = True           # pulse EN to reset the board
time.sleep(0.1)
ser.rts = False

The firmware now checks GPIO0 at boot and complains if it is stuck low. I wrote that check last. It should have been first.

[BTN] BOOT/PTT (GPIO0) = LOW (stuck!)
[BTN] WARNING: GPIO0 held LOW by USB chip — PTT may not work.
[BTN]   Try: close other serial tools, or disconnect/reconnect USB.

2. The whisper

The INMP441 on this board is quiet. Talking straight into it gave a peak of 841 on a 16-bit scale, about 2.5% of the range. Sent as-is, the server transcribed everything as noise, which is fair, because it mostly was.

The fix is gain in software, with clipping, on every 20 ms block before it goes out:

#define MIC_GAIN 20

for (int i = 0; i < numSamples; i++) {
    int32_t amplified = (int32_t)micBuf[i] * MIC_GAIN;
    if (amplified > 32767) amplified = 32767;
    else if (amplified < -32768) amplified = -32768;
    micBuf[i] = (int16_t)amplified;
}

Normal speech now peaks around 16,000, which is where speech recognisers are happy. Twenty times is a lot, and it amplifies the hiss too, but the recogniser copes and the alternative was silence.

3. The echo

Once the assistant could hear me, it could hear itself. The mic is a few centimetres from the speaker. It would answer, the mic would pick the answer up, the server would transcribe its own voice as my next question and reply to that, and the two of them would happily carry on without me.

Real echo cancellation is a project on its own. Push-to-talk makes it unnecessary. The device is strictly half-duplex: audio only leaves the board while the button is held, and pressing the button flushes the playback buffer and zeroes the speaker's DMA buffer, so the assistant shuts up the moment I start.

// on PTT press
ringFlush();
i2s_zero_dma_buffer(SPK_I2S_PORT);

It talks

With those three sorted, boot looks like this:

==========================================
 ESP32-S3 Push-to-Talk — Thalamus Live
 Board: ESP32S3-AI V2.2 (N16R8)
==========================================
 Chip: ESP32-S3 Rev 0
 PSRAM: 8386279 bytes
 Free Heap: 332980 bytes

[Mem] Ring buffer: 48000 bytes in PSRAM
[WiFi] Connected! IP: 192.168.0.233
[WiFi] RSSI: -38 dBm
[I2S] Mic initialized (16kHz, I2S_NUM_1)
[I2S] Speaker initialized (24kHz, I2S_NUM_0)
[WS] Connected to Thalamus!
[Session] Started: esp32-aitoy-001

I held the button and asked it who it was. The speaker answered in the demo persona's voice, and the log showed both halves:

[PTT] >> Recording started
[user] Hello, who are you?
[PTT] << Recording stopped
[assistant] Hi! You're through to Summer from Sum Vivas, how may I help you?
[Turn] 1 complete

A £20 board with no documentation, talking. It is a small thing and I was unreasonably pleased with it.

The pin map

This is the table I wish had been in the bag. If you buy this board under any of its names, it is the same board.

Table 2. ESP32S3-AI V2.2 (303ESP32AI2), verified.
FunctionGPIONotes
INMP441 WS4I2S word select
INMP441 SCK5I2S bit clock
INMP441 SD6I2S data in. L/R is tied to ground, so read the left channel only
MAX98357 DIN7I2S data out
MAX98357 BCLK15I2S bit clock
MAX98357 LRCK16I2S word select
volume−39active low, internal pull-up
volume+40active low, internal pull-up
BOOT0active low. Only readable if the serial port is opened with DTR deasserted
WiFi reset—wired to EN. It resets the chip and cannot be read
EN—hardware reset
LED48active high
PIR header?GND / OUT / VCC on the back. I have not mapped OUT yet
speaker, bat—JST plugs, labelled on the back
on-off—slide switch, cuts the battery

What it cost

ItemCost
the board, speaker and cable£20.43
PlatformIO, Arduino, two librariesnothing
Total£20.43

Next time

  1. Buy a board with a schematic. The boards the Xiaozhi project actually recommends come with one, and with firmware that already works. Most of my afternoon was pin discovery that should have taken a minute.
  2. Use ESP-IDF instead of Arduino. The Arduino I2S driver is the old, deprecated one. ESP-IDF 5 has a channel-based I2S API with better buffer control and less latency.
  3. Lose the button. Espressif's ESP-SR library has an echo canceller built for the S3's two cores. With that the device could listen all the time and the button could go.
  4. Compress the audio. Raw 16 kHz PCM is about 32 kB/s upstream. Opus would make that 3 to 6 kB/s with no audible delay, which matters on office Wi-Fi.

Two things worth remembering

GPIO0 on any ESP32 board with a USB-serial chip is a trap. It looks like a normal button. It behaves like one in a test sketch. Then it betrays you the moment a serial monitor opens the port with DTR on, because the CH340X or CP2102 or FTDI on the board is holding it low for you. Test buttons with the cable in and the monitor open, or you are testing a different device from the one you will use.

INMP441 levels vary a lot between boards. Some breakouts put gain in front of the I2S bus. Cheap integrated boards don't. Measure the raw peak first, then add gain in software with clipping.

For the money the board is better than it has any right to be. The I2S routing is clean, the buttons make sense once you know where they go, and the battery circuit means a 3.7 V LiPo would make it properly portable. It just needs someone to publish the schematic. Until then there is the table above, and the firmware, the test sketch and the serial scripts are all in the repo.