Seventeen songs, or one synth

Every LED pole carries its own animations, so it still has something to show when the controller is gone. We wrote seventeen of them by hand. Then we stopped, and started building the thing that makes them instead. This is what that means, and the bit of arithmetic that ambushed us at the end: a pole with twelve columns of LEDs cannot represent its own twelve evenly spaced directions in the single byte the firmware stores angles in.
The thing that runs when nothing else does
A PoleFX pole is a tube with six strips of LEDs up it, driven by an ESP32 the size of a stamp. Normally a Raspberry Pi feeds it sixty frames a second over the network. But the Pi is not always there. It boots. It gets unplugged. Somebody restarts something mid-show.
So every pole also carries its own animations, running on the ESP32 with no controller involved. Seventeen of them, written one at a time over a couple of years: smoke, caustics, a sonar ping, rain, twinkle.
Each one is a single equation. Not "a program that draws smoke", literally one function that takes a position on the pole and a timestamp and returns a color. That is the whole pattern. It is a lovely way to write the first seventeen and a terrible way to write the eighteenth, because each one fuses three decisions into a single lump of arithmetic: what the texture is, what shape it takes, and what color it comes out.
Fused means unrecombinable. Smoke's turbulence cannot be poured into sonar's ring. If you want that, you write pattern eighteen from scratch, tune it by eye until it looks good, then measure it to make sure it still hits sixty frames a second.
The synth analogy, which is most of this post
The seventeen patterns are seventeen recordings. Each one is finished. You can play them. You cannot take the bassline out of one and put it in another.

What we are building now is a synthesizer. A synth does not store songs. It has a fixed chain of parts in a fixed order, oscillator into filter into envelope into amplifier, and that chain never changes. What changes is a small block of settings, a patch. A few hundred bytes of patch and one machine that only ever does one thing, between them, give you an unreasonable number of sounds.

Layer B picks a shape: stripes, a helix, a chevron zigzag. Layer A picks a texture: a plasma field, a flow, drifting smoke. The combine stage decides how the two meet, and which one you pick matters enormously. Multiply and the geometry becomes a stencil, cutting the texture into bands. Screen and it becomes a glow laid over the top. Difference and you get interference, moire, the two fields fighting. Then colorize turns the resulting brightness number into actual light, through the palette the owner already chose.

Every panel there is the same texture and the same geometry. One byte differs.
One more join, the dashed arrow, is what stops the output looking like layers. Warp lets layer B's value bend layer A's sense of direction before layer A is evaluated at all. Without it you get a texture with a shape cut out of it. With it, the shape drags the texture around, and it reads as one material rather than two stacked pictures.

The patch is 48 bytes and we call it a genome
(Status note: 48 bytes is the format as of this post's date, 2026-07-28, the first working one, and it is the one in the figure. It grew to 64 later that same day: the original 46-byte payload, a 16-byte modulation block and a two-byte checksum, and it is still 64 as of 2026-08-05. Everything below about fixed layouts and named fields survives; the size is implementation state and should be read with a date on it.)

That is a complete pattern. Not a description of one, the actual thing: which generators, how fast each layer scrolls and spins, how the two combine, which palette slots the colors come from, and a checksum so a corrupted one gets rejected rather than rendered.
Forty-eight bytes is smaller than this sentence. It fits in a URL. You can text one to somebody. The firmware has four engine slots for them, seeded for now from compiled defaults; saving your own into flash is part of the design and not yet wired in. And because a genome is a fixed layout rather than a program, every field has a name and a legal range, so a slider can nudge one value without any risk of producing something that will not run.

Four different byte strings, four looks, one engine. Nothing above is a photograph or a mock-up: that is the actual core rendering onto the actual twelve-column geometry, from the same portable C++ the pole compiles for its own processor and the browser compiles to WebAssembly, with parity tests holding the browser and host builds to identical frames.
We call it a genome because the format exists to be generated. Feed a random number in, get a pattern out. That is the actual goal: a pole that invents new looks on its own, forever, without anybody writing pattern eighteen.
Which is where it stops being fun and starts being engineering.
Why the chain is fixed
The obvious objection: why not let people wire the boxes up however they like? Node graphs are a solved idea. Shader editors do it. At least one well-known controller in this space just lets you write expressions and gets on with it.
We will be looking at these, to be clear. The plan has a curation pass in it and it is exactly the boring job it sounds like: a seed dial in the browser preview, a couple of hundred fresh random patterns shuffled blind with a handful of the seventeen mixed in, scored one at a time on two questions. Would I leave this running on a pole? Is it a new look, or a recolor of one I saw four seeds ago? Speed is not a human's job at all: the bench harness loads a couple of hundred random patterns onto a real pole and takes the worst frame time across the lot, with a logic analyzer on the LED data lines as ground truth rather than the firmware's opinion of its own frame rate.
So if we are reviewing them anyway, what is the fixed chain for? You cannot review them all. Forty-eight bytes holds vastly more patterns than anyone could watch in a lifetime, and a thousand scored by eye is indistinguishable from zero coverage. There will always be one nobody has seen, rolled ten minutes from now by somebody spinning the dial on a pole in a venue.
What curation can do is tune the distribution. Score two hundred, notice that high-frequency geometry with a hard edge reliably looks like a broken television, turn that corner of the space down until it stops coming up. The claim is then not "every pattern is good" but "the overwhelming majority of what this emits is good, and here is the sample that says so", which is a claim you can actually support. Tuning a distribution is tractable over a fixed shape with bounded fields. Over free-form graphs it is a research project, because most of that space is degenerate in ways that are hard to even describe. That last part is a judgment call rather than a result, and it is the judgment that picked the architecture.
Should you be allowed to write your own anyway
Yes, and you already can. The seventeen are still there, still the taste benchmark, and pattern eighteen is still a file somebody writes. The fixed chain constrains what the generator may emit, not what a human may make. Porting something in from another ecosystem, I have no objection to on principle either; it is a hand-authored artifact, it gets benched like any other, and the only caveat is the honest one, that stepping outside the chain steps outside the guarantees.
What blocks it is cost, not principle. Those patterns are scripts, and on this chip running a script means an interpreter making decisions per pixel. On a chip already spending roughly 2.6 microseconds per pixel, with an interrupt handler fighting it for instruction cache, per-pixel interpretation is precisely what there is no room for. That is arithmetic against the frame budget rather than a benchmark: nobody here has put an interpreter on this chip and timed it. It is the same reason our own chain resolves which generator to call once when a pattern loads, not 2,880 times a frame. The machine that does have the compute, and no sixty-frames-a-second obligation of its own, is the Pi driving the whole rig. If arbitrary scripting turns up, that is where it belongs.
Sixteen point six seven milliseconds
Sixty frames a second means a frame every 16.67 ms. That is not a target, it is a wall: overrun it and the update is late, and a pattern that overruns it routinely is visibly dropping frames.
Here is what makes it tight. Pushing colors out to 2,880 LEDs takes 14.83 ms by itself, and that is pure electrical signalling, the chip clocking bits down a wire. It happens whether you calculate anything or not. The saving grace is that the ESP32 can compute the next frame while the current one is going out, like loading the dishwasher while it runs. That overlap window makes the maths mostly free, right up until it takes longer than the wire does; it is a hiding place, not a free pass, since compute still contends for cache and interrupts with everything else the chip is doing.
The unoptimized worst crossfade misses the 16.64 ms deadline. Optimization and fading through black bring compute below both the deadline and the 14.83 ms LED wire time.
The top bar is where we started, and it fails. Not by a little: 24.4 ms against a 16.64 ms wall. Worse, the case that fails is not some exotic pattern, it is two patterns cross-fading. When one animation dissolves into the next, both have to be computed for every pixel for that whole second. The budget silently halves exactly when the pole is doing the most visible thing it ever does.
The second bar is one compiler flag. The engine is about twenty tiny functions calling each other, and the project's default optimization setting was leaving them as twenty actual calls rather than welding them together. Turning it up for this one file took 24.4 ms to 13.6, and made the compiled code smaller, which is the opposite of what you expect and happens because inlined functions stop needing the ceremony of being called.
The third bar is not a flag, it is giving something up. We stopped cross-fading engine patterns into each other and started fading through black: one out, the next in. There is a moment of darkness where a smooth blend used to be, and in exchange only one pattern is ever being computed. 13.6 ms to 7.4. A dip to black is less elegant than a dissolve, and it bought back roughly half the frame budget, which is what makes the rest of the feature list affordable at all.
The rounding nobody ordered
Now my favorite thing in this whole build. It is a bug with nothing to do with the engine, and it had been sitting in the geometry the entire time.
The pole is a cylinder with twelve columns of LEDs around it. To draw anything angular the firmware needs to know which direction each column faces, and it stores that as a single byte: 0 to 255, all the way around. 256 steps to a full turn.
Twelve does not divide into 256.
256 ÷ 12 = 21.33, so the stored angles are 0, 21, 43, 64, 85, 107, and the gaps alternate 21, 22, 21, 21, 22. The columns are physically evenly spaced on the actual pole. Their recorded angles are not. It is a clock face with twelve hours marked on a ruler with 256 divisions.
Eight- and sixteen-column layouts have no angular storage error. The twelve-column layout does, producing 4–20 wrong hard-edge positions depending on the clamp.
Each column is off by at most a third of one step, which is nothing. Except a stripe pattern multiplies that angle: ask for six stripes around the pole and you multiply the error by six too. If the stripe has a hard edge, a sharp line between lit and unlit, a column sitting near that edge gets pushed to the wrong side of it. It does not get slightly dimmer. It goes from full brightness to black.
We had written this hazard into the spec months ago with a fix attached: when a pattern has hard edges, limit how many stripes it can have. The engine had no hard edges in it until last week, so the fix had never been tested. Building the stripe generator finally made it testable, so we tested it, expecting to confirm it.
It is not right. At the "safe" limit a hard-edged stripe still lights the wrong column, at 8 of the 256 positions the pattern can scroll through, and when it goes wrong it takes four of the twelve columns with it. The safe limit misbehaves at more positions than the unsafe one it was meant to replace.
The green bars are the actual finding: simulated poles with 8 or 16 columns carry no angular rounding at all. Both counts divide 256, so the stored angle is the true angle bit for bit and there is nothing for a stripe count to multiply. Only three stripe settings per geometry were simulated, but that part does not need simulating: an exact angle stays exact whatever you multiply it by. What the green bars do not cover is anything that is not angular storage, so read them as zero rounding error rather than as a pole that cannot be wrong. The variable was never the number of stripes. It was whether the column count divides 256, a fact of the pole's geometry, not a knob a pattern can turn. Our flagship pole has twelve columns.
Nothing is on fire here: it is one column of one hard-edged pattern blinking at a few scroll positions, and most patterns are soft enough that it never shows. But a fix we had confidently written down does not do what it says, and we only know that because the spec told us to run the cheap experiment before freezing the numbers. It took an afternoon and came back with the opposite of what we predicted. The real fix is probably to stop storing angles in one byte, which is a change to something much older and more load-bearing than the engine, so it is written down and not yet done.
Where this is
As of the last week of July 2026: the synth exists and it makes sound. Two texture generators, three geometry generators, the combine stage, the warp, color. Fifteen or so more parts to build, then the randomizer, then the seed dial and the two hundred yes-or-no decisions. This inventory will age faster than anything else in this post.
The browser preview runs the same C++ compiled to WebAssembly rather than a JavaScript reimplementation, and a parity harness diffs the browser build's frames against the native build's, so there is no hand-written second version to drift out of sync with the one the pole runs. What that does not check yet is the pole. The harness diffs WebAssembly against a host build, both from the one source; nobody has diffed either against frames off the ESP32, and the corpus that would is written down and unbuilt. So when it looks right in a tab, that is the pole's own source saying so, not yet its own silicon.
What keeps snagging me is that a pattern used to be a function somebody wrote, and now it is a few dozen bytes. The question moved with it. It used to be "can we make this look good". It is now "can we make almost every possible one of them look good", which is a harder question, a better one, and still open.