Teaching Eight States to Sing

A weekend of hacking on PRISM — my programmable FSM peripheral riding shotgun on TinyQV, now in real silicon on the TinyTapeout Sky 25a shuttle.

8 STATES 44-bit STEW 90 MHz OVERCLOCK 1-bit DAC @ 1.05 MHz 8.3× ADPCM vs DSM ncurses OVER UART

PRISM — the Programmable Reconfigurable Indexed State Machine — is a peripheral I taped out alongside Michael Bell's TinyQV RISC-V core. The concept: instead of one fixed-function peripheral, ship a tiny runtime-loadable Mealy FSM (eight states, each packed into a 44-bit STate Execution Word "STEW") surrounded by a useful datapath — a 24-bit counter/shift register, an 8-bit counter with compare (or it can be an 8-bit shift register), a small FIFO, a comm register. Load a different bitstream (I call them chromas) and the same silicon becomes a different peripheral.

PRISM peripheral block diagram

▣ media/prism_periph.png — the PRISM peripheral: 8-state FSM core with its counter / shifter / FIFO / comm datapath (click for full size)

This weekend the question was: now that it's real silicon, what can it actually do? The answer escalated from register pokes to ZZ Top, and it did not stop there.

0x01The SDK: prism.c / prism.h

First order of business was a proper driver layer. The interesting quirk of PRISM's configuration space is that the State Information Table (the program/chroma memory) is latch-based and not randomly addressable — you shift whole STEWs through it like a bucket brigade, and the oldest word recirculates to a readback register. So prism_load_chroma() doubles as a verification pass: as the new chroma shifts in, the previous one shifts out, and you can check every word of it on the way. Area-frugal silicon caused by fierce, inviolable 2-tile competition rules forces honest software!

0x02First contact, one register at a time

Before anything clever, there was an evening of typing at the prism> console: load a chroma, set breakpoints on state indices with bp/bc, halt, single-step, poke count1 preloads and count2 compares by hand and watch the input/output vectors move. Unglamorous, but this is where you learn your silicon matches your simulation — and PRISM has real hardware debug (halt, step, two breakpoints, force-state), which paid for itself many times over before the weekend was out.

0x03The DSM chroma, or: my noise was my own fault

Chroma number one: turn PRISM into a 1-bit delta-sigma DAC pushing about a megabit per second out one pin, into the audio PMOD's 200 kHz low-pass filter. The companion tool, mp3_to_dsm.py, decodes audio and modulates it into the bitstream. It grew up in three painful steps:

LESSON — When the output sounds like noise, check the math last and the bit order first.

The payoff — ZZ Top's Sharp Dressed Man, played by an 8-state FSM through one pin! (this was before identifying the incorrect shift direction):

▶ media/ZZT.mov — 1-bit DSM playback via the PRISM DSM chroma

0x04WS2812: pre-silicon code meets real LEDs

The WS2812 chroma was written and simulated before tapeout, so this was the moment of truth: it drove an AdaFruit 7-NeoPixel Jewel with zero FSM changes. The application on top was ported from a previous life where it ran on an ECP3 FPGA behind PCIe — now it's the same LED show, driven by a peripheral the size of a rounding error.

▶ media/ws2812b.mov — NeoPixel Jewel on the WS2812 chroma

0x05The synthesizer

Next chroma: an 80's-style synth. PRISM generates a 1 MHz PWM carrier whose duty cycle is the volume (the comm register), while the 24-bit counter runs the note oscillator — the CPU just retunes preloads and walks envelopes. Presets, a little arpeggiator, then a demo mode with song tables, and suddenly the debug console had a set list.

Somewhere in here I started nudging the system clock: 90 MHz runs clean on my board; 91 does not. A 40% overclock on TinyTapeout silicon is a nice margin to know about when you're counting cycles. But for now, we are sticking with 64MHz.

0x06The L4Z detour

DSM audio is a megabit per second — flash fills fast. Like 5MB for 30s of audio! So: write a small LZ4-class decompressor ("L4Z"), stream-decompress from flash to RAM in the background of an interrupt-driven player, save huge space. The decompressor worked beautifully! The compression ratio did NOT.

LESSON — Properly noise-shaped, dithered 1-bit audio is high-entropy by design. The modulator's whole job is to fill the noise floor with maximally random bits. You cannot compress what you have deliberately randomized.

The streaming machinery survived the pivot, though — it became the backbone of everything in section 0x09.

0x07tqv.py: the board as a scriptable object

Reflashing through a browser gets old by the third iteration, so the weekend got a command-line tool: tqv.py flash programs the QSPI flash through the RP2350 and drops you on the design's console; load pushes a song into the spare 8 MB PSRAM bank over SPI at roughly 100× UART speed; console reattaches anytime. The underrated consequence: the whole board became scriptable. Regression tests type commands and assert on output — and an AI assistant spent most of the weekend flashing builds, driving the console, and measuring results with a logic analyzer while I supplied taste and bug reports.

0x08The realization: the glue was built for this

Staring at the synth chroma, it clicked: the fixed-function plumbing around the FSM is accidentally a perfect sample-playback engine. Three details conspire:

That's a race-free 8-bit PWM DAC with single-byte sample delivery. I'd like to claim I planned it. I co-designed the register map while writing early drivers, and this is where that investment quietly paid out.

0x09ADPCM: real songs in reasonable flash

With the PCM chroma in hand, the codec question answered itself: IMA ADPCM in independent 1024-sample blocks — 8.3× smaller than DSM — decoded on the fly by the CPU with a fused lookup table. (A "clever" branchless rewrite of the decoder was slower; on a core that fetches over QSPI flash, instruction count is king and short branches are cheap. Measure, don't guess.) The toolchain grew a --format adpcm mode, and songs became .bin blobs you can park in PSRAM (playr) or in the upper 8 MB of flash (playf), where they survive power cycles. Three minutes of audio in 2.7 MB, zero underruns, played by a peripheral that is nominally a state machine.

0x0AC++ lands in the SDK

The endgame needed C++, so the SDK learned it: an .init_array section in the linker script, a constructor walk in the runtime, the embedded subset flags (no exceptions, no RTTI), and eventually a real heap carved out of RAM A with operator new over newlib malloc. Along the way, two finds worth admitting to:

0x0BThe TUI: ncurses over a UART, on bare metal

The finale. Years ago I wrote a curses-based debugger TUI for an earlier, bigger PRISM living under NuttX — my own CTui framework, my termcurses terminal driver I wrote and contributed to NuttX years ago, pdcurses 3.4 in between. This weekend that whole stack came to bare-metal TinyQV: threads stubbed out (the design's poll loop never actually needed them), five POSIX calls shimmed onto the UART, and the terminal itself answers the ESC[6n size query so the UI comes up at your real window size. That's 8 years worth of code from different project all wrapped into one tidy little package (yes, the TUI is from 2018 and termcurses from 2021). The old layering held up almost embarrassingly well — the effort went into lifetime bugs the NuttX heap had been silently forgiving: an uninitialized member that only mattered on a reused heap block, an endwin() that stored through a freed-and-nulled pointer straight into the flash address window and wedged the bus.

On top sits a new CPrism backend wired to the real SDK:

▶ media/theTUI.mov — Full TUI demo in browser playing synth(s) and audio

Every console command with tab completion and history, a live watch window on the FSM state, chroma .lst listings in tabs with the halted state highlighted as you single-step, the Verilog sources with syntax highlighting for reference — and a Notes tab where the synth streams its note progression in real time, melody notes in bright yellow. The same hardware debug registers from section 0x02, wearing a much better suit.

0x0CWhat the weekend taught me

Still open: a selftest resume quirk in the config/debug corner that doesn't reproduce interactively, and the STEW mapping rules deserve real documentation before the next chroma author (probably me) rediscovers them the hard way. But right now there's an eight-state FSM on my desk running ZZ Top through a low-pass filter, and a full-screen debugger watching it happen over one UART. Good weekend.