Building the Desk

radiobananas · live
radiobananas · live
loor.news · Armenian
loor.news · Armenian
weather · generative sky
weather · generative sky
choose life · spoken
choose life · spoken

Choose DIY, and wondering who the fuck you are on a Sunday morning

AI changed coding and developer careers forever. And it's causing a lot of grief, uncertainty and excitement in the field, all at the same time. People are losing their sense of craft, and it definitely feels like a jump into a more industrialized digital world. The hope is that craft, and the feeling of having accomplished something, can still be found by aiming higher: with AI doing more of the typing, people can try bigger and more ambitious things in their careers than they could before.

On a smaller scale, another thing I found that helps with the burnout, and with the sense of losing interest in software engineering, is playing with hardware and custom devices. Even with Claude, plugging things together, writing generating C/C++ code, flashing it onto the board and ending up with a cute custom device like my radio (I stream my own music!) returns some sense of craft to you, and the fact that, at the end of the day, it's a gimmick is its own act of rebellion.

Here I ported my existing side projects to C++ from scratch (there's no browser and no Linux on this device, just a tiny real-time kernel and our own code). Now I have a tiny desk companion running my own radio, radiobananas.art, where everyone hears the same song at the same second; my own Armenian news site, loor.news; artsy pieces like trainspotting; plus a generative weather sky and a clock with the room's temperature and humidity. I control it from my phone over Wi-Fi. It felt really good to see all these projects compiled together like this.

More to come: I want to make little music album cards with NFC stickers on them, and turn this device into an NFC reader that plays the album when a card touches it. I hope my kid finds it and likes it when he's a bit older.

Meanwhile, I have lots of ideas for what to do next with tiny devices like this one, from running LLMs on them to some nice visualizations. Let's see.

The above text was written by me. What follows is the technical story of how we got here. I asked Claude to write it, but I checked, nudged and directed it along the way, and I think it's an interesting read.

The technical journey

Written by Claude.

The board

Waveshare ESP32-S3-RLCD-4.2. A 400×300 black-and-white reflective screen with no backlight, so it looks like paper in daylight. Two 240 MHz cores, 16 MB flash, 8 MB PSRAM, and only 512 KB of fast internal RAM. It also has a speaker amp, two microphones, a temperature/humidity sensor, a real-time clock and two buttons.

desk.local, the remote control

The board has only two buttons, so we built a small web app to control it from a phone over Wi-Fi. The board serves it itself: open http://desk.local on any phone or laptop on the same network and you get the radio (play/stop, volume), the screen switcher, the weather city, room temperature and humidity, news alerts and device info. Nothing runs in the cloud. The page and its small JSON API live inside the firmware.

desk.local: now playing, volume and screen switcher
desk.local: room climate, radio look, news alerts and device info
desk.local on a phone

mDNS makes the desk.local name work on the home network, the same way printers are found. The page installs on the iPhone home screen like an app and has a brutalist look. POSTs must be JSON, so a random website can't control the desk.

Day one

First question: is it alive? We plugged it into the Mac and it answered right away. No drivers, no adapters.

Next: which of the chip's tiny metal pins lead to the screen, the speaker and the buttons? Waveshare's website doesn't say, and a wrong guess can damage the board. So we copied them from Waveshare's own example code.

Before changing anything, we backed up the board's whole memory, like backing up a phone before an update. It took three and a half minutes, and it turned every later scare into a small one: we could always go back to how it came out of the box.

Language

The board is built around an ESP32-S3 chip made by Espressif. There are a few ways to program it. One is Arduino, the beginner-friendly toolkit known from hobby boards, and we didn't use it. We used ESP-IDF, Espressif's own official toolkit, and wrote everything in C++. It gives full control over the chip's two processor cores, its memory and its storage, which we needed to run audio, Wi-Fi and the screen at the same time. Waveshare's own audio examples use it too.

Rust was tempting, but the audio drivers and Waveshare's code are written in C, so most of the work would have been glue between the two languages.

Wi-Fi with no password in the code

The board starts its own Wi-Fi network and shows a QR code to join it. A catch-all DNS answers every lookup with the board's IP, which makes the phone open the "sign in" page like hotel Wi-Fi. You pick your home network, the credentials go to flash, and it reboots online.

The radio

radiobananas.art plays one shuffled playlist to everyone, in sync, computed from the date and the UTC time. The board had to compute exactly the same thing. The site's shuffle turned out to be Mulberry32-style, and its seed overflows 32 bits, so the C++ wraps it the same way JavaScript's >>> 0 does.

A test pulls seededShuffle straight out of the site's index.html, runs it in Node next to the C++, and compares 6,288 cases. All identical.

Playback uses HTTP Range requests: read the MP3 header and seek table, jump to "now + 1 s", stream from there. The decoder drops frames when it's late and waits when it's early. The first time it worked, Johnny Cash was at second 207.08 on both the board and the site.

Running out of RAM

The next HTTPS request failed with esp-aes: Failed to allocate memory. Free internal RAM was 0. We had filled the small fast RAM with task stacks and playlist strings. The fix became a rule: big data and network task stacks go to PSRAM, through a small custom allocator (PsVector, PsString). We hit this wall three more times.

No sound

Logs said audio was playing. Codec registers over I2C said everything was on. The speaker was in the box, unplugged.

One app, many screens

At first the radio and the news were two separate programs, and switching between them meant reflashing from a laptop. So we made the board one app with screens, like a phone with apps. The KEY button flips to the next screen, holding it stops the radio, and the BOOT button does something different on each screen. Any screen can pop up a banner over the others, so breaking news appears even while the radio is on. A new idea is now one new screen and one line of code.

Armenian fonts

loor.news is in Armenian, and the graphics library has no Armenian fonts. Armenian is left-to-right with no letter joining, so we generated bitmap fonts from Noto Sans Armenian at build time, and the server just sends JSON.

The device also needed a small addition on the loor.news side. loor.news has its own AI agent, so we wrote it a short brief and it built that part.

Contrast

A reflective screen can't be brighter than your room. The dark radio design was mostly black pixels, and dithered photos print darker than expected (dot gain, same thing printers deal with). We went with a light theme and re-dithered the photos with a gamma lift. Waveshare already drives the panel voltage near its max, so we left that alone.

Favourite bug: in the light theme, the "b · a · n · a · n · a · s" letters around the photo disappeared. Small black letters on a grey dither pattern look exactly like the pattern. We put them on a white border.

Gapless

Track changes had 2.7 s of silence (new TLS connection, probe, seek). Now the next track downloads into the same buffer right after the current one, and the decoder switches over on its scheduled second. Gap: about 20 ms.

Weather

The sky is drawn pixel by pixel with Bayer dithering: sun with turning rays, moon and stars, clouds, rain, snow, lightning, fog, over a small landscape with a house whose window lights up at night. The first moon looked like an eclipse; a bright disc with a dim shadow side fixed it. The clock got Instrument Serif and an italic "47 seconds past" line.

CHOOSE LIFE

The last port was trainspotting: one sentence scrolling forever on a wave, spoken as it passes. Colour became dither density, the glow became a dithered halo. The voice is embedded in the firmware and borrows the speaker from the radio while the screen is open.

The first build crash-looped at boot, too fast for USB to come up. I had called the MP3 decoder, which needs ~16 KB of stack, from the 3.5 KB boot task. Recovery: hold BOOT while plugging in, flash in download mode, watchdog reset.

Then speed: 28.6 ms per frame down to 9.8 ms (~36 fps). Glyphs pre-rotated into columns, 8–32 pixels per operation, hot buffers in fast RAM only while the screen is open, audio decoding on the other core, display bus doubled to 20 MHz.

Tools

Numbers

Firmware2.2 MB, 5 screens
Radio sync test6,288 cases identical to the site
Track change gap2.7 s → ~20 ms
CHOOSE LIFE frame28.6 ms → 9.8 ms
Free fast RAM0 KB → ~45–55 KB
Boards bricked0