ESP32 based client for LocalAI
This is a basic demonstration on how an ESP32 with a digital microphone and oled display can act as frontend to LocalAI. It can be compiled and installed using the Arduino IDE
- ESP32 with PSRAM. For this project a LOLIN S2 Mini was used
- INMP441 I2S Microphone
- 0,96 Zoll OLED Display I2C 128 x 64
TamAIgotchi talks to LocalAI through the OpenAI-compatible API. Two models are used, and the exact names are sent by the firmware:
| Purpose | Endpoint | Model name sent | Set by |
|---|---|---|---|
| Speech-to-text | POST /v1/audio/transcriptions |
whisper-1 |
hardcoded in the LocalAI-ESP32 library |
| Chat completion | POST /v1/chat/completions |
gpt-4 |
TamAIgotchi.ino (chat.setModel("gpt-4")) |
Both requests carry an Authorization: Bearer <api_key> header, so the key configured on the ESP32 (default sk1234567890 in config.h, changeable on the setup page) must match the one LocalAI is started with. If LocalAI runs without LOCALAI_API_KEY, any value works.
The transcription model name is hardcoded to whisper-1 in the library, so you need a model registered under exactly that name. LocalAI serves whisper through the whisper (whisper.cpp) backend:
-
Install the backend:
local-ai backends install whisper
-
Download a whisper.cpp weight file into the
modelsfolder (example:whisper-base.en):mkdir -p models wget https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin -O models/whisper-base.en
-
Register it as
whisper-1(e.g. inmodels/whisper-1.yaml):name: whisper-1 backend: whisper parameters: model: whisper-base.en
Any small LLM works — it just has to be named gpt-4 (or aliased to that name). Example with Llama 3.2 1B:
-
Install the backend:
local-ai backends install llama-cpp
-
Download a GGUF weight file into the
modelsfolder (example:llama-3.2-1b):wget https://huggingface.co/bartowski/Llama-3.2-1B-Instruct-GGUF/resolve/main/Llama-3.2-1B-Instruct-Q4_K_M.gguf -O models/llama-3.2-1b
-
Alias it to
gpt-4(e.g. inmodels/gpt-4.yaml):name: gpt-4 backend: llama-cpp parameters: model: llama-3.2-1b
git clone https://github.com/a-i-a-d/TamAIgotchi.git
cd TamAIgotchi
arduino-ide TamAIgotchi/TamAIgotchi.ino
The LocalAI API URL and key are not hardcoded. They are stored by the ESP-Wifi-Config library and configured through the same web setup page as the WiFi:
- The values in
config.h(api_url/api_key) only act as initial defaults (first boot / after a full reset). - Change them at runtime: open the setup page (
http://<device-ip>:8080, orhttp://192.168.1.1:8080in AP mode), go to the Custom tab, enter the API URL and key, and press Save. The device reboots and picks up the new values. - Please make sure to escape dots (.) in the URL string, e.g.
http://192\.168\.1\.5:8080/v1/.
Note: the setup page requires a login. The default credentials are username
admin, passwordpass_ESP— you can change them on the Security tab of the setup page.
The WiFi credentials are not hardcoded. They are stored by the ESP-Wifi-Config library and configured through a web setup page:
- If the ESP32 can connect to a known network, the display shows the IP address it was assigned.
- If it can't reach a known network, it starts an access point (named
TamAIgotchi_<mac>). The display shows the access point name and its IP address (192.168.1.1). Connect to that access point and open the setup page (http://192.168.1.1:8080) to enter your WiFi SSID and password.
Note: the setup page requires a login. The default credentials are username
admin, passwordpass_ESP— you can change them on the Security tab of the setup page.
To compile the program you'll need to install the following libraries in the Arduino IDE:
- Adafruit SSD1306
You'll require two modified libraries:
1. ESP-Wifi-Config (fork with the 63-character WiFi password fix + user-extensible settings)
The stock ESP-Wifi-Config truncates WiFi passwords to 30 characters, so this project uses a fork with the fix:
- Download ESP-Wifi-Config v2.3.0 (or the release asset
ESP-Wifi-Config-2.3.0.zip) - In the Arduino IDE click Sketch->Include Library->Add .ZIP Library...
- Select the downloaded ESP-Wifi-Config-2.3.0.zip file
2. LocalAI-ESP32 (modified OpenAI-ESP32 for LocalAI)
- Download LocalAI-ESP32 library
- In the Arduino IDE got click Sketch->Include Library->Add .ZIP Library...
- Select the downloaded LocalAI-ESP32-0.0.1.zip file
- Connect your ESP32 board via USB
- Select the correct ESP32 board in the Arduino IDE
- Click the Compile and Upload button
The code is fully class-based (the refactoring in #42). Each class lives in
its own TamAIgotchi/<name>.h/.cpp pair; TamAIgotchi.ino is the app shell
(setup() + loop() + the object wiring):
| Class | Role |
|---|---|
App |
the IDLE / RECORDING / SENDING / RESPONSE state machine |
Display |
the single render pass (render()) + the display-level actions |
Recorder |
the PSRAM recording buffer + the record → transcribe → LLM flow |
AlienAnimation |
the idle-alien animation |
Bubble |
the speech bubble (stored text, scroll, rendering) |
StatusBar |
the top two status lines (21 chars × 2) |
Button |
the debounced buttons (isPressed() / isLongPressed() / isHeld()) |
Led |
the recording LED |
Hardware |
the shared library objects + the hardware bring-up (init()) |
Free functions that stay free: wrapText() (TamAIgotchi/text_utils.cpp) and
the setup() / loop() shell in TamAIgotchi.ino. All user-facing display
strings live in TamAIgotchi/messages.h.
The host-side test harness lives in tests/ (shimmed Arduino/ESP32 headers —
no board needed). Run it with:
bash tests/run_tests.shThe 128×64 OLED is split into three regions, drawn by the StatusBar,
AlienAnimation and Bubble classes and composed in the single
Display::render() pass. The top two lines are the status bar; the bottom half
holds the alien (lower-left) and the speech bubble (right), which carries all
content (prompt, response, and the idle hello):
+--------------------------------------------------+ y=0
| status line 1 (max 21 chars) |
+--------------------------------------------------+ y=8
| status line 2 (max 21 chars) |
+--------------------------------------------------+ y=16
| | +--------------------------------+ |
| | | speech bubble | | x=30..126
| alien | | (prompt / response / hello) | | y=18..62
| (4..27 | | 15 chars x 5 lines | |
| 42..63) | +--------------------------------+ |
+--------------------------------------------------+ y=64
Buttons (all wired to GND, INPUT_PULLUP):
| Button | Short press | Double press | Hold 5 s |
|---|---|---|---|
| GPIO3 (main) | — (it is the hold button) | — | hold-to-record: hold while recording (max 10 s), release to send |
| GPIO9 | scroll down one line | jump to end | reset WiFi settings & reboot into the setup AP |
| GPIO11 | scroll up one line | jump to start | exit the response view back to the idle screen |
The main button (GPIO3) is hold-to-record — the hold is not a 5 s threshold action, it records for as long as it is held (capped at 10 s) and sends the take on release. The two scroll buttons are only active while the response is on screen. Status messages must fit 21 chars × 2 lines — longer text goes to the bubble or the serial log, never the display driver clipping.
The buttons are Button class instances (buttons.h): update() once per
loop pass, then isPressed() / isLongPressed() / isHeld() (debounce
50 ms, long-press threshold 5000 ms).
Hold the button to record: the LED lights up and the microphone records audio for as long as you keep the button pressed (max 10 s). When you release the button (or the 10 s limit is reached), the recording is sent to your LocalAI whisper model and gets transcoded into a text. The text then is sent as prompt to the LocalAI gpt4 model and the response is shown on the oled display.
The LLM response can be longer than what fits in the speech bubble. It is shown as a scrollable 5-line window with a Response x/y counter in the status bar (first visible line / total lines). Use the two side buttons to scroll (a double press of the same button jumps to the start / end):
| Button | Short press | Double press | Long press (5 s) |
|---|---|---|---|
| GPIO9 | scroll down one line | jump to the end | reset WiFi settings & reboot into the setup AP (existing behavior) |
| GPIO11 | scroll up one line | jump to the start | exit the response view back to the idle screen |
| GPIO3 (main) | start a new recording (same as when idle) | — | — (it is the hold-to-record button) |
Both scroll buttons are only active while the response is on screen; during recording / sending they are ignored.
After 60 s without any button press (and while WiFi is connected), a small pixel-art alien appears in the lower-left corner of the display and loops between:
- a speech bubble saying
hello(4 s), - jumping up and down while waving (16 s),
- a speech bubble saying
hello(4 s), - jumping and waving (16 s),
- standing still (30 s).
Any button press stops the animation immediately and returns to the normal record → transcribe → prompt → response flow. After the response has been shown for 60 s without a button press, the animation loop starts again.
All timings are configurable via the ALIEN_* defines in TamAIgotchi/config.h; the bubble text is MSG_ALIEN_BUBBLE in TamAIgotchi/messages.h.
The sketch can print a detailed serial trace of every step (boot, pin setup, LocalAI settings, OLED init, WiFi mode, I2S init, recording size, transcription/prompt/response lengths, button events) prefixed with [DEBUG].
- Debug output is off by default.
- To enable it, uncomment
#define DEBUGinTamAIgotchi/config.h(or pass-DDEBUGas an extra build flag) and recompile. - When
DEBUGis not defined, allD_TD()/D_TDDEC()/D_TDLN()calls compile away to nothing — no runtime cost. - The regular user-facing status and error lines (shown on the OLED and mirrored to serial) are always printed, independent of the debug switch.
