Arduino sketches for the addressable LED desk-backlight project (UNO + 5V WS2812B)
  • C++ 95.5%
  • Nix 4.5%
Find a file
aaronjan 7871c2e9b0 Read RIGHT I2S slot so a floating L/R mic pin still works
The INMP441's L/R pin is left unwired (floating), so its channel select
is electrically undefined. It had been sitting on the LEFT channel (which
the sketch read via I2S_STD_SLOT_LEFT) but drifted to RIGHT, leaving the
LEFT slot reading pure zeros -> silent audio animations. Confirmed by
dumping raw I2S swing: LEFT = 0, RIGHT = live signal. Read the RIGHT slot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:49:45 -07:00
blink-test initial project scaffold 2026-06-15 11:18:50 -07:00
demoreel100 initial project scaffold 2026-06-15 11:18:50 -07:00
led-animations Read RIGHT I2S slot so a floating L/R mic pin still works 2026-07-07 23:49:45 -07:00
led-count initial project scaffold 2026-06-15 11:18:50 -07:00
led-strip initial project scaffold 2026-06-15 11:18:50 -07:00
led-test initial project scaffold 2026-06-15 11:18:50 -07:00
mic-spectrum Audio-reactive: FFT mic pipeline + LED spectrum + docs 2026-06-19 21:13:43 -07:00
mic-test Audio-reactive: FFT mic pipeline + LED spectrum + docs 2026-06-19 21:13:43 -07:00
.gitignore Per-animation settings + glitter weather modes 2026-06-19 16:00:22 -07:00
flake.lock initial project scaffold 2026-06-15 11:18:50 -07:00
flake.nix ESP32 Wi-Fi control: web UI, OTA, live sliders, persistence 2026-06-19 14:24:13 -07:00
README.md Audio rework: 'swell' mountain visualizer; doc mic-off-before-OTA 2026-06-20 22:12:10 -07:00

arduino

Arduino sketches for the addressable LED desk-backlight project (ELEGOO UNO R3 + 5V WS2812B strip). Project notes and build log live in the zettelkasten: Inside/Projects/addressable LED strip behind desk project/.

Dev shell

arduino-cli is provided by the flake — it is not installed system-wide, which is why a bare arduino-cli fails. Enter the shell first:

nix develop

On a fresh machine, install the core and library once (they persist in ~/.arduino15 / ~/Arduino, outside this repo):

arduino-cli core update-index
arduino-cli core install arduino:avr
arduino-cli lib install FastLED
arduino-cli lib install arduinoFFT   # for the audio-reactive / mic-test FFT

Build & upload

cd <sketch-dir>            # e.g. led-animations
arduino-cli compile --fqbn arduino:avr:uno .
arduino-cli upload -p /dev/ttyACM0 --fqbn arduino:avr:uno .

Upload needs write access to /dev/ttyACM0; the user is in the dialout group via the NixOS config (log in again after that change took effect, or newgrp dialout).

Sketches

Sketch What it does
blink-test/ onboard LED blink — proves the board runs
led-test/ 10-LED red/green/blue/white cycle — first-light validation
led-count/ chase + decade-marker pattern to count strip length (300 LEDs)
led-strip/ full 300-LED scrolling rainbow
demoreel100/ FastLED DemoReel100, 6 auto-rotating patterns
led-animations/ all animations in one sketch; ESP32 = Wi-Fi web control, UNO = startup default
mic-test/ INMP441 over I2S → FFT → 8 frequency bands printed to serial (audio pipeline bring-up, with noise gate + auto-gain)
mic-spectrum/ audio-reactive: the FFT bands drive the strip as colored zones, bass→treble (standalone, pre-merge)

All strip sketches set NUM_LEDS 300 and a 4A current cap (setMaxPowerInVoltsAndMilliamps(5, 4000)) for safety on the 5A supply. Data pin is auto-selected: GPIO5 on the ESP32, ~6 on the UNO.

ESP32 (current controller)

The ESP32 core ships prebuilt binaries that run on NixOS via the flake's nix-ld setup. One-time core install (persists in ~/.arduino15):

arduino-cli core update-index
arduino-cli core install esp32:esp32

Compile and USB upload (board enumerates as /dev/ttyUSB0):

arduino-cli compile --fqbn esp32:esp32:esp32 led-animations
arduino-cli upload -p /dev/ttyUSB0 --fqbn esp32:esp32:esp32 led-animations

Wi-Fi credentials (secrets.h)

led-animations/secrets.h holds the Wi-Fi SSID/password and the mDNS name. It is gitignored because this repo mirrors to a public Forgejo — never commit it. On a fresh checkout, copy the template and fill it in:

cp led-animations/secrets.h.example led-animations/secrets.h
$EDITOR led-animations/secrets.h

Wi-Fi web control

Once flashed, the ESP32 joins the network and serves a one-page UI at http://desk-leds.local/ (or the IP printed to serial at 115200 baud — mDNS can be flaky, the raw IP always works). The page has:

  • a button per animation (demoreel, rainbow, cylon, fire, pacifica, pride, scratch),
  • a brightness slider (0–255; the 4A cap still auto-limits it),
  • a speed slider (1–10; the shared hue-advance rate),
  • a glitter dropdown — a fading white overlay applied over any animation: Off / Twinkle / Drizzle / Rain / Snow / Thunder.

Each animation keeps its own brightness/speed/glitter (changing one doesn't touch the others), and all of it is persisted to NVS flash, so the strip powers back up exactly how you left it.

OTA (wireless reflashing)

After the first USB flash, the firmware runs ArduinoOTA, so subsequent uploads go over Wi-Fi. The flake provides an ota helper:

ota                 # compile led-animations + push to 10.0.50.127 over Wi-Fi
ota <sketch> <ip>   # override either

Turn audio mode OFF before an OTA push. This was the single biggest cause of failed/aborted uploads ("No response from device", or "Broken pipe" partway in). While audio mode is on, the loop does a blocking I²S mic read (~32 ms/frame) that starves ArduinoOTA.handle(), so the board can't service the push reliably. Drop audio mode over HTTP first, push, then turn it back on (it auto-returns to the audio animation):

curl "http://10.0.50.127/set?mic=0"   # stop the blocking mic read
ota
curl "http://10.0.50.127/set?mic=1"   # back to audio mode after it reboots

With the mic off, OTA typically completes on the first try.

Firewall: espota has the board connect back to the laptop on a random high port, which the NixOS firewall blocks. Drop it for the push, then restore:

sudo systemctl stop firewall
ota
sudo systemctl start firewall

(A permanent fix is to trust inbound from the LAN subnet in the NixOS config.) A large OTA image needs a stable link; the firmware sets WiFi.setSleep(false) and retries the join on boot to keep the marginal signal usable.

mDNS gotcha: desk-leds.local can get hijacked by the router's DNS pointing at another LAN host (you'll get an nginx 404, not the board). The board is reliably at its IP (10.0.50.127) — use that, or set a DHCP reservation.

Animation dev workflow

The fun loop, from tweaking a number to inventing a pattern:

  1. Add an animation — write a myThing_run() that draws one frame + calls FastLED.show(), then add it to the Anim enum, animName(), and runCurrentAnim(). Its button appears on the web page automatically.
  2. Iterate on hardware — edit → ota → look at the strip → repeat (~20–40 s per cycle). Use the scratch animation as a sandbox so you don't disturb the working patterns.
  3. Tune live, no reflash — the brightness/speed sliders change the strip instantly over HTTP; only code changes need a reflash.
  4. Prototype without hardware — Wokwi simulates an ESP32 + WS2812 strip in the browser. Set NUM_LEDS small (~30) and get the pattern math right before burning ota cycles.
  5. Debug — plug in USB and arduino-cli monitor -p /dev/ttyUSB0 -c baudrate=115200 to read serial (boot log, IP, crash traces). This is the one job that still needs the cable.

Audio-reactive (INMP441 mic)

An INMP441 I²S MEMS mic makes the strip react to sound. Wiring (mic → ESP32):

Mic pin ESP32 note
VDD 3V3 not 5V — it's a 3.3 V part
GND GND shared common ground
L/R GND ties the mic to the LEFT I²S slot (which the code reads)
WS GPIO25 word select (LRCLK)
SCK GPIO26 bit clock (BCLK)
SD GPIO33 data out of the mic

The audio pipeline was built in stages, each testable on its own:

  1. mic-test — I²S read → arduinoFFT → 8 log-spaced bands (so the spacing matches how we hear pitch). Printed to serial as bars.
  2. Noise gate + AGC (also mic-test) — a gate that learns each band's floor only while the room is quiet, plus auto-gain that scales the loudest band to full, so it self-calibrates to any volume / mic placement. Attack/decay smoothing makes the bars move musically.
  3. mic-spectrum — the same pipeline now drives the LEDs: 8 colored zones, bass (red) → treble (violet), each zone's length tracking its band. (Standalone bring-up sketch; the merged in-firmware version moved past this look.)

In led-animations, audio mode is toggled at the top of the web page and swaps in the audio animation set. The live one is swell: a slow drifting rainbow baseline with broad "mountain" swells that rise at random spots on each musical onset (red = low pitch → blue = high), widen out like a wave, then settle. It keys off transients (gOnset[]), not raw loudness, and runs at full brightness so the peaks read as mountains on the wall. The early band-as-bars looks (spectrum / glow / pulse) were removed — they read as vertical VU meters on a horizontal strip.

Tip: tune the audio feel over serial first, but judge motion on the LEDs — a scrolling terminal can't show smooth motion and will mislead you.

Lessons learned (the hard way)

Things this project taught me, kept here for the next person (or next me):

  • A "common ground" is the data signal's voltage reference, not just a power return. WS2812 data is a voltage relative to ground; the controller and the strip must share the same ground or the data garbles. Two diagnostic fingerprints that saved hours:
    • Strip lit but showing garbage / spasming = lost signal ground (power's fine, the reference isn't).
    • Only the first LED glows, rest dark = the strip's 5 V isn't connected — LED #1 is sipping parasitic power through the data line.
  • Bare jumpers on a header are mechanically fragile, and crowding makes it worse. A cramped row of ground jumpers gave bizarre, logic-defying symptoms — an unrelated wire appearing to "fix" the LEDs — because inserting a neighbor was unseating the one ground that mattered. Minimize crowding, and solder the permanent build (especially the ground bridge).
  • Isolate with a known-good control. Re-flashing the known-working LED sketch proved instantly that a failure was wiring, not the new mic/code — stop debugging the thing you just changed until you've ruled out the rest.
  • Verify grounds with a multimeter (continuity, and wiggle while testing) — a pin can be "ground" and still make intermittent contact.
  • OTA gotcha: espota has the board connect back to the host on a random port, so a host firewall blocks it; drop the firewall for the push. And WiFi.setSleep(false) is what keeps a marginal link responsive enough for OTA.
  • Classic ESP32 (WROOM-32) is 2.4 GHz only — no 5 GHz networks.