Most DIY voice assistants ship with a personality baked into firmware. VA5 ships with an empty one.
Hold the button, speak, release — and it answers back in a real conversation, not a pre-recorded line. On the round display, an animated emoji face reacts in real time, and the model itself picks the mood via a function call. A NeoPixel tells you whether it's listening, thinking, or speaking. The whole build is about $20 plus a 3D-printed case.
The part that makes it worth building: everything that gives it a character lives behind a web page on your phone. Connect once through the captive portal, then browse to voice-agent-XXYY.local and you get a 12 KB system-prompt field, ten OpenAI voices, a language selector, and an emoji editor where you drop in a GIF, PNG, JPG or MP4 and the face changes immediately. No recompile, no serial cable, no SD card. Settings persist in NVS. The shipped emoji set is stored separately and never overwritten, so there's no way to brick the thing by customising it.
That design decision came out of the classroom. VA5 is used in schools where students research a historical, philosophical or literary figure, write the persona themselves, and then have to hold a conversation with what they wrote.
A group wrote Jacob. The class interviewed him and decided he was far too nice to be the Jacob they'd been studying — the one who buys a birthright off his starving brother and impersonates him for a blessing. So the group went back and wrote him again. That's the whole thing in one story: write a character, talk to it, find out where your reading was thin, rewrite. It happens to be prompt engineering, except nobody has to call it that.
Thirty students can't each be flashing firmware, which is why all of it had to work from a phone. And because personas export as a .zip, one set of devices cycles between classes — each group loads their own characters at the start of the period and takes them away at the end.
UNDER THE HOOD
ESP32-S3 N16R8 (16 MB flash, 8 MB OPI PSRAM) talking to the OpenAI Realtime API for speech-to-speech over secure WebSockets. Dual-core FreeRTOS split: Core 0 owns WiFi, the WebSocket and Base64; Core 1 owns I2S in and out, the button and the display. They pass audio through PSRAM ring buffers with a 300 ms jitter buffer and a low-water rebuffer threshold.
Audio is an INMP441 I2S MEMS mic in and a MAX98357A into a small 4/8 Ω speaker out, both at 24 kHz PCM as the Realtime API requires. An optional PCM5102A DAC adds a 3.5 mm jack for headphones or a powered speaker, sharing the I2S bus and switched at runtime from the portal. The face is a GC9A01 240×240 round SPI TFT running seven moods × eight pre-rendered RGB565 frames at ~8 fps out of an 11 MB LittleFS partition.
The model can call three tools mid-response: set_display_emotion drives the face, set_volume means you can just say "quieter please" and have it stick across reboots, and show_network_info puts the IP and token count on screen.
WHAT WAS ACTUALLY ANNOYING
ArduinoWebsockets 0.5.3 can't make a WSS connection on ESP32 — SecuredEsp32TcpClient has no setInsecure(), and the ESP32 branch of upgradeToSecuredConnection() never falls back. Both fail silently, which cost real time. The library is vendored in src/ with both fixes applied and the patches documented. Anyone else pointing an ESP32 at a WSS endpoint will hit this.
TFT_eSPI was its own fight — v2.5.43 crashed on tft.init() with StoreProhibited, inside a global constructor, before setup() ran at all. Arduino_GFX_Library worked first try on the same pins.
BUILD IT TWO WAYS
Breadboard with jumpers, or the custom 2-layer KiCad board in pcb/ — gerbers and drill files exported and ready for JLCPCB or PCBWay, with a spare-GPIO expansion header broken out. That board has been ordered, assembled, and is the unit running here, not an unverified first spin. The 145 mm circular enclosure is a parametric build123d model with STLs pre-generated for both build paths.
Everything is open: github.com/iollama/Voice-Assistant-5
Udi Tirosh