~/writing/gometer-130fps-led
130 frames per second, derived from a wire
I built a Go daemon that drives a music visualizer on a Pi. The frame rate wasn't mine to pick; the LED protocol had already set it. The real work was a latency budget I had to measure before the lights felt locked to the music.
How fast does a music visualizer need to refresh? I thought that was up to me.
I assumed I’d pick a number. Sixty fps, maybe, tune the render loop until it hit that, done. But the number was already set by the protocol on the wire that drives the LEDs. My job was to compute it and build everything else to fit.
The project is gometer: a Go daemon on a Pi that reads live audio out of squeezelite, runs an FFT, and paints a 32x16 WS2812 panel as a spectrum analyzer with a phosphor trail. The FFT isn’t the interesting part. “Looks fine” and “feels locked to the music” are separated by about twenty milliseconds, and you don’t get from one to the other by guessing.
The frame rate is on the wire
WS2812 is a one-wire protocol clocked at 800 kHz. Every pixel is 24 bits, so at 800 kHz that’s 30 µs per pixel on the wire. After the last pixel you hold the line low for a reset latch, about 50 µs, so the strip knows the frame is done and commits it. That’s the entire timing model, fixed by the protocol.
The panel is 256 pixels. So a full frame costs:
frame = pixels * per_pixel + latch
= 256 * 30 us + 50 us
= 7730 us
= 7.73 ms -> ~129 fps7.73 ms, about 129 fps. That’s the ceiling. You can’t clock the bits out faster than the strip reads them.
The two halves of the panel clock in parallel on PWM0 and PWM1. You’d expect 512 pixels to take twice as long, but the two channels shift simultaneously, so a full update is still 7.73 ms, not 15.5. The parallelism doesn’t buy speed past the ceiling; it buys the ceiling at full panel size instead of half.
The render loop has a ticker set to a 500 µs sleep, nominally 2 kHz. It doesn’t run at 2 kHz. It runs at about 129 Hz, and that’s correct.
ticker := time.NewTicker(500 * time.Microsecond) // nominal 2 kHz
defer ticker.Stop()
for range ticker.C {
frame := vis.Render(spectrum())
// pa.Write blocks until the DMA has clocked every bit to the strip.
// The loop does not free-run at the ticker rate. It settles at the
// wire rate, because this call will not return until 7.73 ms have passed.
pa.Write(frame)
}pa.Write blocks until the DMA finishes shifting the frame out. The ticker keeps me ready to start the next frame the instant the previous one commits. The loop self-clocks to the hardware. I didn’t set 129 fps anywhere; the wire set it.
The latency budget is the real product
Frame rate is throughput: how often a new picture appears. It doesn’t tell you whether that picture shows the audio you’re hearing now or the audio from a quarter second ago. The second one decides whether the thing feels alive.
There’s a threshold, roughly 40 ms, past which a sound and a corresponding flash of light stop registering as the same event. Inside it, your brain fuses them. Outside it, the lights look like they’re reacting to the music rather than being it. So the whole design has one constraint: total audio-to-LED latency under 40 ms, ideally well under.
I measured the path and added it up.
The FFT window is the tension in the whole budget. A longer window resolves lower frequencies: 14 ms of audio gets me down to about 70 Hz, low enough to see a kick drum and a bass line as distinct things. But a longer window means more audio has to accumulate before I can transform it, which is latency. Longer blurs transients and pushes past the fusion threshold; shorter turns the low end to mush. Fourteen milliseconds is where those two pressures balance for this panel.
Add it up: FFT window, transform and mapping, frame time, buffering in between, and the audio reaches the LEDs about 22 ms after it reaches the speakers. Inside 40. A beat lands on the panel at the same instant it lands in your ears, and I kept that number under the ceiling deliberately.
Throughput is not latency
A high frame rate with a deep buffer in front of it gives you smooth motion that arrives late. The panel looks great and feels dead. Frame rate decides how the motion looks; latency budget decides whether it’s the music or a recording of the music a moment ago.
The panel cannot do red, and other physics
With timing settled, color was next. WS2812 panels are bad at red. The red die sits at about 620 nm, and a saturated, fully-on red reads as a dim blip next to the green and blue, which blast out far brighter. Drive pure red at full value and the panel looks barely on.
The first instinct is to crank red and pull the others down to match. That works for exactly one panel. Real WS2812 batches vary enough that a balance tuned on one strip looks wrong on the next, so the correction had to be a per-deployment knob, not a constant baked into the binary. The default gains lean on the channels that are already too strong:
// Per-deployment, because WS2812 batches vary enough that a constant lies.
// Pull green and blue down toward the weak red die instead of overdriving red.
var ColorBalance = [3]float64{1.0, 0.85, 0.85} // R, G, B gains
func balance(r, g, b uint8) (uint8, uint8, uint8) {
return scale(r, ColorBalance[0]),
scale(g, ColorBalance[1]),
scale(b, ColorBalance[2])
}The second color problem was transitions. A spectrum analyzer wants to sweep cool to warm as energy rises, blue up through cyan and orange into red. Interpolating RGB directly is wrong: the midpoint of blue and red is a muddy purple that, on a low-contrast LED panel, reads as a smear with no edges. So I route the transition through complementary pairs that hold their contrast: red against cyan, orange against blue. Those survive a low contrast ratio because the eye reads them as opposed rather than as a gradient toward gray. Motion stays legible instead of dissolving into purple mush.
The last piece is why 129 fps is worth having at all, given that film gets away with 24. Flicker fusion, the rate past which a flashing light looks steady, is around 60 Hz for most people. At about twice that, the panel is inside persistence of vision, where successive frames blend into continuous motion in the eye itself. A bar climbing the panel doesn’t step from pixel to pixel; it reads as smooth sub-pixel motion, because by the time your retina has let go of one frame the next two have already arrived.
The phosphor trail decay is two-stage to match a real phosphor. A bright half-life of about one frame, so a freshly-hit bar snaps to full brightness and drops fast, and a dim half-life of about 50 ms underneath it, so the tail lingers and fades the way an old CRT does. One stage alone looks wrong: a single fast decay has no trail, a single slow decay smears everything into a glowing blur. Two stages give a sharp leading edge with a soft tail behind it.
The bug that needed a full restart
gometer reads audio out of squeezelite through a shared-memory segment that squeezelite exports for its VU meter. To find it, the daemon scans /dev/shm for segments named squeezelite* and attaches to one. The original code took the first match:
func findSqueezeliteShm() (string, error) {
entries, _ := os.ReadDir("/dev/shm")
for _, e := range entries {
// os.ReadDir returns lexical order. The first squeezelite* segment
// is not necessarily the live one. This is the bug.
if strings.HasPrefix(e.Name(), "squeezelite") {
return filepath.Join("/dev/shm", e.Name()), nil
}
}
return "", errSegNotFound
}os.ReadDir returns entries in lexical order, so “the first match” is the alphabetically first match, which has nothing to do with which segment belongs to the running player. squeezelite doesn’t always clean up its segment when it dies, so a stale segment from a dead PID can outlive the process and sort ahead of the live one. The visualizer would lock onto a dead segment and react to silence, or to whatever garbage was last left in that buffer.
The fix reads the segments instead of trusting the order. Each one carries a running byte and an updated timestamp. Stat every candidate, check whether it’s live, and among the live ones pick the most recently updated:
func findSqueezeliteShm() (string, error) {
entries, _ := os.ReadDir("/dev/shm")
var best string
var bestUpdated uint64
for _, e := range entries {
if !strings.HasPrefix(e.Name(), "squeezelite") {
continue
}
hdr := peekHeader(filepath.Join("/dev/shm", e.Name()))
// running == 0 is a corpse. Among the live ones, newest updated wins.
if hdr.running != 0 && hdr.updated > bestUpdated {
bestUpdated = hdr.updated
best = filepath.Join("/dev/shm", e.Name())
}
}
if best == "" {
return "", errSegNotFound
}
return best, nil
}That fixed lock-on at startup, but not the other half of the same bug: a player that started fine and then went dark. The daemon would lose audio mid-session and need a full restart. The segment it had attached to was still there, still mapped, just no longer being written, because the player had moved to a new one. Discovery only ran once at startup, so nothing ever went looking again.
The answer was a stagnation timer. If no frames have arrived for five seconds, the audio has stopped flowing through the segment we hold, so we drop it and re-run discovery. That turned “loses audio and needs a restart” into “loses audio for five seconds and re-locks onto the live player.” Same bug as the startup lock-on, slower: don’t trust a handle to stay valid just because it was valid when you got it.
The control UI
The visualizer has a small web control UI: brightness, palette, which visualization. I built the first version with shadcn and React, and didn’t like it. A single-page app with a JSON API behind it, talking to a daemon whose whole job is to push pixels to a wire as fast as the wire allows. The frontend had more moving parts than the thing it controlled.
I rebuilt it with htmx. Server renders HTML, buttons post to it, page swaps in fragments. There was no JSON API to design because no client needed one. Three knobs and a daemon that never blinks.
What this actually was
I went in thinking the FFT or the color would be where I got stuck. The FFT is a library call. The color was physics I could measure and correct. What bit me was a number I never got to choose and a budget I had to keep under a threshold I couldn’t see.
The frame rate was on the wire the whole time: 256 pixels, 30 µs each, 50 to latch, 7.73 ms, 129 fps. I didn’t set it. I computed it, built the loop to fall into it, and spent the rest of the budget making sure the light arrived while the sound was still in the air.
The code is on GitHub.