A Kirby-shaped desktop companion that listens to spoken questions, answers them with AI through a speaker, and shows a little personality on a round display — and doubles as an offline study timer when you just want to focus.
Combine the embedded and hardware skills from my earlier projects with something more interactive — a device with a personality, not just a circuit on a bench.
The goal was a small desktop companion that could listen to a spoken question, process it with AI, and answer out loud, using a display and physical touch to make it feel like a character rather than a gadget. It also had to be useful when offline: a built-in study tool with a checklist mode, a Pomodoro timer, and a plain study timer, so the device earns its desk space even with no network.
One button flips Kirby between a focused study tool and a talking AI assistant. The voice loop runs entirely on the device, calling out to the cloud only for the heavy AI work.
The display and touch sensor layer personality and interaction on top: waking from sleep, reacting to touch, and moving Kirby's mouth in time with the reply.
Press the button → Kirby greets you → records your question → transcribes it → asks the LLM → speaks the answer, with the mouth animated to the audio. Then loops back to listening.
Checklist mode, a Pomodoro cycle with breaks, and a plain countdown timer — all working with no WiFi, so the device is useful even when the AI isn't reachable.
Multiple Kirby expressions on the round TFT, plus an audio-reactive mouth that opens in proportion to how loud the speech is, for a natural talking look.
A capacitive touch sensor wakes Kirby and drives interactions; a dedicated button toggles AI mode. Idle → sleeping face; tap → awake and smiling.
If WiFi or the AI service fails, the device reports why on screen and falls back to the offline study tool instead of hanging or looping.
Digital microphone in and Class-D amplifier out over two independent I²S ports, with the amplifier hardware-muted between phrases to keep the speaker silent when idle.
Off-the-shelf modules chosen so each subsystem could be tested on its own before integration. Two components are wired for the next iteration and not yet in the software path.
Every subsystem was proven in isolation first. This turned out to be the single most useful decision of the whole build — when integration broke, I always had a known-good reference to compare against.
A. Display
Verified the GC9A01A could render the different Kirby expressions cleanly at full frame.
B. Touch
Confirmed the TTP223 as a reliable physical input for waking and interaction.
C. Audio
Ran the INMP441 mic and MAX98357 amp together in a standalone loopback sketch to establish reliable input and output on the new wiring.
D. AI pipeline
Verified the round trip from the ESP32 to the cloud AI service and back, end to end.
The interesting part. Integrating the working subsystems surfaced a chain of bugs that each looked like a hardware fault but turned out to be software — and finding them is what pushed the design toward its final form.
The speaker was completely silent — no AI voice, and not even a boot-time test beep. The serial log showed the I²S driver reporting a clock-pin conflict.
The I²S pin struct's first field is the master-clock pin. I'd left it out of the initializer — but omitting a field doesn't leave it unset, it gets zero-initialized to GPIO0. Both the mic and amplifier ports silently tried to output a master clock (which neither chip even uses) on GPIO0. The second port to initialize was refused its pins entirely, so the amplifier ended up with no data line.
Set the master-clock pin explicitly to NO_CHANGE on both ports, and check the driver's return codes instead of assuming success.
A silent default masqueraded as working code. Every hardware-init call is now checked and logged, so a failure announces itself instead of hiding.
Every cloud request failed at the TLS handshake even though the log showed ~93 KB of heap free.
A secure connection needs a single contiguous ~40 KB block — total free heap says nothing about the largest available block. A ~96 KB audio buffer parked in the middle of RAM had fragmented the heap into pieces too small for the handshake.
Allocate the recording buffer only while actually recording, and release it the instant the audio is uploaded — so every handshake runs against an unfragmented heap.
Recording length is now bounded by available RAM (I dropped it from 4 s to 3 s), and the buffer can't be held persistently. This constraint is the direct reason for moving to the ESP32-S3 with more memory.
After adding network-time sync, everything went dead — touch and button unresponsive, display frozen on the boot face.
The time-sync step waited for a server in a loop with no timeout, inside setup(). When the server didn't answer, setup never finished, so the main loop never ran. The sync wasn't even needed, because certificate validation was disabled.
Removed the blocking step entirely.
Never block startup on a network call without a timeout. A frozen device looks identical to a dead one.
After moving the mic and amp to new pins, the audio behaved as if the wiring hadn't changed, and two peripherals were fighting over the same GPIO.
An old set of pin definitions still overrode the new wiring, and two of the stale pins overlapped the new ones.
Consolidated everything to a single authoritative pin map — one source of truth the whole sketch reads from.
Duplicate configuration is a bug waiting to happen. Hardware pins get defined exactly once.
In AI mode the face flickered between two different Kirby bitmaps, and the talking mouth appeared as a hard rectangle stamped over the face.
Each state redrew a full-screen bitmap, and the open mouth was a flat filled box whose single colour never matched Kirby's shading.
Keep one face on screen the whole time; change only the mouth and a small status dot. The mouth is redrawn by re-blitting the real artwork underneath and drawing the opening on top, so the motion looks natural.
A small status dot (listening / thinking) instead of a separate "thinking" face — chosen for a continuous, single-character feel over a two-frame slideshow.
When an AI turn failed, the device looped listening → thinking → fail → listening endlessly, flickering with no error.
Added a failure counter that stops after three attempts, shows a plain-language error on the display (which step failed, and the status code), and drops back to the offline study tool.
Retry logic needs an exit. Failures should be legible to the user, not silent.
One more, earlier on: the voice pipeline started on a different cloud AI provider and moved to another after friction with keys, model names, and audio formats. Because the pipeline is provider-agnostic — speech-to-text → LLM → text-to-speech — swapping services was a contained change rather than a rewrite.
Every fix above bought something and cost something. Naming the costs honestly is part of the engineering.
Recording length is capped by RAM so the secure handshake has room. This is the constraint driving the ESP32-S3 upgrade — more memory buys back longer questions.
The device skips TLS certificate checks to keep the handshake light. Acceptable for a hobby device on a home network; flagged as future hardening.
A listening/thinking dot instead of a distinct thinking face — a deliberate choice for a continuous, natural character and lighter redraws.
The AI needs connectivity and adds round-trip latency. Mitigated by the fully offline study mode, and by evaluating lower-latency services (fast LLM inference, dedicated speech) for the next revision.