Root Meridian Part 3: The Needle Flies, the Speaker Speaks
Last post ended with a known open item: a delay(16) in the main loop was putting a ceiling on how fast the stepper motor could actually move. The motor worked β it just wasnβt moving anywhere near the speeds the AccelStepper library was configured for. I said Iβd fix it once the full system was wired together.
This session I fixed it, added audio, and heard the whole thing work for the first time. It sounds exactly like I imagined.
Finally Fixing the delay(16)
The issue: AccelStepper needs stepper.run() to be called as often as possible. It manages its own step timing internally using micros(). When I had delay(16) blocking the loop, the motor was only getting a chance to step ~62 times per second β nowhere near the 1800 steps/second target for erratic mode.
The fix wasnβt complicated. The delay(16) was there to rate-limit the LED animation to ~60fps, which is sensible. But it was wrong to rate-limit the whole loop when only the LEDs needed that cap. The solution: move the timing logic into the LED function itself, so each subsystem owns its own cadence.
// Before: entire loop blocked at ~60fps
void loop() {
motorUpdate(); // wants to run every microsecond
ledUpdate();
delay(16); // blocking everything for 16ms
}
// After: motor runs every iteration, LEDs self-gate
void ledUpdate() {
static uint32_t lastLED = 0;
uint32_t now = millis();
if (now - lastLED < 16) return; // LED's own rate gate
lastLED = now;
// ... render LEDs
ring.show();
}
void loop() {
motorUpdate(); // runs every iteration, no delay anywhere
ledUpdate(); // returns immediately if <16ms has passed
}
The difference was immediately obvious. Erratic mode β where the needle is supposed to whip around chaotically before locking on β actually looked chaotic for the first time. The slow lock-on crawl felt properly dramatic. Before this fix I was listening to AccelStepperβs speed settings and wondering why they werenβt doing anything.
The broader lesson: each subsystem should own its own timing. Donβt let one componentβs cadence requirements bleed into everything else.
Adding the DFPlayer Mini
The DFPlayer Mini is a small serial-controlled MP3 player. You wire it to the ESP32 via UART, send it a play command, and it handles everything else β SD card reading, MP3 decoding, amplification, speaker output. For a prop, itβs basically perfect.
The wiring is six connections:
DFPlayer Mini ESP32 HUZZAH32
βββββββββββββ ββββββββββββββ
VCC ββββββββ 3V
GND ββββββββ GND
TX ββββββββ GPIO 16 (UART2 RX)
RX ββ[1kΞ©]ββ GPIO 17 (UART2 TX)
SPK_1 ββββββββ Speaker +
SPK_2 ββββββββ Speaker β
The 1kΞ© resistor on the RX line protects the DFPlayerβs input from the ESP32βs 3.3V signal. Itβs not optional β the module can be damaged without it. Everything else is direct.
The SD card slots directly into the DFPlayer module. Files go in a /mp3/ folder with exactly 4-digit names: 0001.mp3, 0002.mp3, etc.
The WOKWI_SIM Flag
I wanted to keep the firmware working in the Wokwi simulator as well as on real hardware. Wokwi doesnβt have a DFPlayer component, but it does have a buzzer I can drive with tone(). Rather than maintaining two codebases, I added one compile-time flag:
#define WOKWI_SIM 0 // 1 = buzzer simulation, 0 = real DFPlayer
void audioPlay(uint8_t track) {
#if WOKWI_SIM
// Distinct tones for each event β at least you can hear something happening
static const uint16_t freqs[] = {0, 1400, 800, 1800, 200, 1600};
static const uint16_t durations[] = {0, 80, 600, 500, 400, 300};
if (track >= 1 && track <= 5) tone(BUZZER_PIN, freqs[track], durations[track]);
#else
dfPlayer.play(track);
#endif
}
Set WOKWI_SIM 1 for the simulator, 0 for real hardware, recompile. Done.
I learned this the hard way: I wired up the whole thing, flashed the firmware, and heard nothing from the speaker. Spent several minutes suspecting a wiring problem. It was WOKWI_SIM 1. The firmware was sending tones to a GPIO 4 buzzer that didnβt exist on my bench.
Sourcing the Sounds
I needed five audio tracks. The βopen source equivalentβ for audio is CC0 (public domain) or CC-BY (attribution required). freesound.org is the best place for this β filter by license, search by vibe.
Hereβs what I landed on for each moment in the compass sequence:
| Track | Moment | Sound |
|---|---|---|
| 1 | Erratic spin starts | Ratchet β frantic mechanical chaos |
| 2 | Lock-on crawl begins | Close-micβd clock ticking β recorded inches from the mechanism |
| 3 | Needle locks on target | A wonky high bell β weird and satisfying |
| 4 | Gem spent | Ethereal enchant β not what I expected but it works |
| 5 | Gem restored | Magic healing spell SFX β exactly right |
All five are either CC0 or CC-BY. Attribution for the CC-BY tracks lives in audio/CREDITS.md in the repo. Itβs easy to forget about attribution when youβre deep in a build β putting it in a tracked file means it wonβt get lost.
Converting WAV to MP3 for the DFPlayer:
ffmpeg -i source.wav -codec:a libmp3lame -q:a 4 0001.mp3
The First Full Test
Everything wired, firmware flashed, serial monitor open. I typed the full sequence for the first time:
spin
The needle whipped around. The ratchet fired.
lockon north
The needle slowed. The ticking clock started. The motor crawled toward north with that satisfying deliberateness Iβd been imagining for months. When it arrived β the bell fired.
gem harlen spent
Harlenβs quadrant dimmed to amber ember. The ethereal sound played.
reset
All four quadrants returned to their pulsing gem colors. The magic healing spell played.
I said βit worksβ out loud to nobody.
The Full Audio Trigger Map
For reference, hereβs every audio event wired in the firmware:
| Command | Audio |
|---|---|
spin | Track 1 β ratchet (chaos begins) |
lockon [dir] | Track 2 β ticking clock (crawling to target) |
| Motor arrives at target | Track 3 β bell (locked) |
gem [player] spent | Track 4 β ethereal enchant (drain) |
gem [player] available / reset | Track 5 β magic healing (restore) |
The compass command β the plain seek to a direction β intentionally has no audio. Itβs meant to feel like a routine adjustment. The spin/lock-on sequence is the dramatic moment; the compass pointing somewhere is just navigation.
Where Things Stand
Two of the three hardware subsystems are validated on a real bench:
- Motor β β seek, erratic, lock-on motion modes working at correct speeds
- Audio β β all five tracks triggering at the right moments
- LEDs β β gem quadrant animations and all states verified in Wokwi, but the NeoPixel ring still needs to be physically soldered and wired. Thatβs the next hardware session.
The DM workflow runs entirely from a serial terminal right now. Next step is making it run from Discord instead β a Node.js bot that accepts !spin or !gem harlen spent from the DM in a private channel and fires the corresponding HTTP command at the ESP32.
Thatβs when this stops being a bench demo and starts being a D&D prop.
Future idea noted during testing: each player gets their own unique gem sound when their advantage is spent or restored, rather than the same tracks for everyone. Saving that for v2.