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.
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.

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.


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.
| Part | Result |
|---|---|
| ESP32-S3 at 240 MHz | fine |
| PSRAM, 8 MB | 8,386,279 bytes, after one fix |
| Speaker, MAX98357 | 440, 880 and 1760 Hz, then a sweep, all clean |
| Microphone, INMP441 | audio, but a peak of 841 out of 32,767 |
| Loopback | my voice out of the speaker, quietly |
| volume− on GPIO39, volume+ on GPIO40 | confirmed, active low |
| BOOT on GPIO0 | confirmed, with a catch |
| WiFi reset | reboots 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.
| Function | GPIO | Notes |
|---|---|---|
| INMP441 WS | 4 | I2S word select |
| INMP441 SCK | 5 | I2S bit clock |
| INMP441 SD | 6 | I2S data in. L/R is tied to ground, so read the left channel only |
| MAX98357 DIN | 7 | I2S data out |
| MAX98357 BCLK | 15 | I2S bit clock |
| MAX98357 LRCK | 16 | I2S word select |
| volume− | 39 | active low, internal pull-up |
| volume+ | 40 | active low, internal pull-up |
| BOOT | 0 | active 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 |
| LED | 48 | active 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
| Item | Cost |
|---|---|
| the board, speaker and cable | £20.43 |
| PlatformIO, Arduino, two libraries | nothing |
| Total | £20.43 |
Next time
- 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.
- 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.
- 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.
- 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.