A polyphonic MIDI synthesizer built entirely in FPGA fabric — the oscillators, filters, envelopes, and effects all run as digital logic on the chip, not as software on a CPU. It's written in Google XLS (DSLX), not hand-written Verilog, and the same engine runs on two very different boards: a Basys 3 (Xilinx Artix-7) and an apf.audio Tiliqua Eurorack module (Lattice ECP5).
Because it was developed headlessly over a network, every feature is verified automatically over USB. And not a single line of it was written by hand: the whole design was built by Claude Code (Opus 4.8), the AI coding agent, through loop engineering — prompts in, a self-verifying build → measure → revise loop out.
The browser front-end (webui/): a Serum/Vital-style panel that drives the FPGA synth live over
USB — oscillators, filter, envelopes, LFO, unison, cross-mod, and effects, plus a 4-part
multitimbral selector, preset browser, and demo player.
- What it is — a 32-voice polyphonic, 4-part multitimbral subtractive synth: oscillators → per-voice resonant filter → VCA, with 2× ADSR, LFO, unison, cross-osc FM/ring-mod, and stereo effects.
- Hardware — one engine, two boards: a Basys 3 (Xilinx Artix-7
xc7a35t, audio over USB) and a Tiliqua Eurorack module (Lattice ECP5LFE5U-25F, analog jacks + a DVI visualiser). The synth is a literal circuit that computes one audio sample per tick — see Two boards, one synth. - Written in — Google XLS (DSLX) compiled to Verilog, plus a per-board shell (Verilog on Basys 3, Amaranth on Tiliqua) for I/O and the block-RAM effects. No hand-written datapath.
- Play it — the panel is live at kazunori279.github.io/xls32-fpga-synth: a browser analog-style panel that drives either board over USB with nothing installed (or drive it from Python). MIDI in, 16-bit stereo audio out. The page needs Chrome — see What you need.
- Built by AI — every line written by Claude Code (Opus 4.8) through loop engineering: a self-verifying edit → build → measure loop, with 175 scored end-to-end tests over USB, run against both boards.
- Start here — the Quick start is the ten-minute path and the User guide is the same ground at length, for either board. Both boards ship a prebuilt bitstream, so neither needs a toolchain. Everything after that is Part II: the Builder's guide builds from source, and Architecture is how it works.
Part I · For synth users — a board, a cable, and Chrome.
- ▶ Quick start — flash it and make a sound, in three steps.
- §1 · Overview — the instrument — what it is, what it can do, and which board is which.
- §2 · User guide — either board at length: flashing, the jacks, the panel, the CC map, and what usually goes wrong.
Part II · For developers — how it is built, and how to build it.
- §3 · Builder's guide — build the bitstream for either board, flash, verify, test.
- §4 · Background & rationale — why XLS, why an FPGA, how it was built.
- §5 · Architecture & design — how the synth works today.
Four companion documents go deeper, split the same way the code is — core + Basys 3 in one pair, Tiliqua in the other:
| per-block deep-dive | build history & friction logs | |
|---|---|---|
| core engine + Basys 3 shell | ARCHITECTURE.md | DEVELOPMENT.md |
| Tiliqua / ECP5 shell | ARCHITECTURE_tiliqua.md | DEVELOPMENT_tiliqua.md |
The 50-minute talk that covers all of the above — with playable audio clips from each milestone —
lives in docs/slides/
(English ·
日本語).
A board, a cable and Chrome. Everything in this part runs from the bitstream committed in this repo and a web page that needs no install — no toolchain, no terminal, and no clone.
Three steps to a Tiliqua making sound: flash it once, plug a keyboard in, and — if you want the full panel — open a web page. Doing this on a Basys 3 instead? That route is §2 · A, and it is the one that wants a terminal.
What you need: a Tiliqua R5 in a powered
Eurorack case, a USB-C cable, and something to listen on — out0/out1 are Eurorack line
level, so go through a mixer, an audio interface or an output module. A display is optional. A
computer or an Android tablet running Chrome is needed for step 1, and again for step 3 if you
want the panel.
- Connect and open the flasher — USB-C into the module's
dbgport, then tiliqua-webflash in Chrome. Pick the module over WebUSB. - Choose
XLS24from the Community list and write it to slot 7. There is nothing to download: the module's maker merged XLS32 into the flasher on 2026-08-22, so it sits in the same list as the factory bitstreams. Power-cycle the case; the bootloader counts down for five seconds — pick slot 7 from the menu once, and every cold boot after that goes straight there.
The list entry is deliberately one netlist behind this repo. XLS24 in the flasher is the archive
that closes at 54.30 MHz, and it is the copy the maker tested across several of his own
Tiliquas before merging.
xls24-r5.tar.gz
here (408 KB) is newer and closes at 56.63 MHz; since 2026-08-25 it has been graded on all
three modules on this desk rather than one. They grade identically and sound identical,
so take the list unless you specifically want the newer routing — in which case download that file
and use the flasher's upload button instead.
This is the only step that needs a computer at all, so borrow one if you have to. Once slot 7 is written the module never asks again.
There is a second archive beside it,
xls32-r5.tar.gz, which is the same synth with 32 voices instead of 24 and belongs in slot 6. It fills 98.3 % of the FPGA where 24 voices fill 93.5 %, and this repo labels it experimental for exactly that reason: it runs here, and it did not run on one of the two modules the module's maker tried it on. Start with slot 7.boards/tiliqua/firmware/README.mdis the long answer.
Move the cable to usb2 and plug in a USB-MIDI keyboard, or put a keyboard straight into
the TRS MIDI-In jack and leave the computer out of it entirely. It is 24 voices across 4 parts,
on MIDI channels 1–4, and a TRS keyboard's own transmit channel picks which part it plays.
Sound comes out of out0/out1 — the stereo pair; the other two jacks are silent by design —
and, whenever usb2 is connected, back up that same cable as a 48 kHz USB audio input at the same
time. If you fitted a screen, it shows one tile per voice.
Connect usb2 to a computer or an Android tablet, open
the panel in Chrome, press POWER, and
allow MIDI and audio input when the browser asks. That is the full instrument: every parameter, a
preset browser, and four demo songs the board plays to itself.
Then read the User guide. Which part the TRS jack plays and how to change it, what to do when a note hangs, what each jack and LED means, how to drive the synth from a DAW or a hardware controller instead of the panel, and the handful of things that reliably catch people out.
A 32-voice polyphonic, 4-part multitimbral subtractive synthesizer: two detuned oscillators and a sub per voice, into a resonant multimode filter and a VCA, with two ADSR envelopes, an LFO, unison, cross-oscillator FM and ring modulation, and a stereo chorus / echo / reverb chain on the end. That is a conventional analog-style layout, and it plays like one — a keyboard, a DAW, or the browser panel over MIDI, and sound out of the jacks.
What is not conventional is where it runs. There is no CPU and no audio thread: the whole instrument is a circuit on an FPGA, computing one sample per tick, so nothing an operating system does can jitter its timing. The circuit is written in Google XLS (DSLX) rather than hand-written Verilog, and the same engine is compiled unchanged for both boards — which is §4 and §5, and none of it changes how you play the thing.
Synth spec — board-independent; everything here is the engine itself.
| Spec | Value |
|---|---|
| Polyphony | time-multiplexed — one voice enters the pipeline every ~24 engine cycles. 32 voices on Basys 3, 24 on the Tiliqua bitstream this repo ships (32 there too, as an experimental build) |
| Multitimbral | 4 parts — MIDI channels 1–4, each an independent patch |
| Synthesis | subtractive: oscillators → per-voice resonant filter → VCA, with 2× ADSR + LFO |
| Oscillators | 2 per voice (detuned dual) + sub-osc → up to 64 oscillators across a 32-voice build, 48 across a 24; 5 waveforms (sine/saw/square/triangle/noise), PWM, cross-osc ring/FM/FM+ (8 ratios) |
| Filter | per-voice state-variable, resonant — LP / HP / BP / notch |
| Envelopes | 2× ADSR per voice (amplitude + filter) |
| Modulation | per-part LFO (vibrato + tremolo), pitch bend (±2 st), portamento/glide |
| Effects | stereo — chorus, ping-pong delay/echo, 8-comb Freeverb reverb (in the shell, not the engine) |
| Sample format | 16-bit signed PCM, stereo out |
| Verification | 175 scored end-to-end cases over USB (FFT / spectrogram), on both boards |
The same instrument ships on two very different pieces of hardware. Both run the identical engine
with the identical feature set — down to the same core/synth.x; what differs is how you hear it,
how you play it, what it takes to get going, and one number, how many notes sound at once before
the engine steals a voice.
| Basys 3 | Tiliqua | |
|---|---|---|
| What it is | an entry-level FPGA development board — Digilent Basys 3, Xilinx Artix-7 xc7a35t |
a Eurorack module — apf.audio Tiliqua R5, Lattice ECP5 LFE5U-25F |
| How you hear it | 16-bit audio back up the USB cable, through the panel or the host/ tools |
Eurorack jacks out0/out1 — and the same audio up the USB cable at once |
| How you play it | over USB: the panel, or any MIDI source the host can reach | USB-MIDI, or a keyboard straight into the TRS MIDI-In jack with no computer at all |
| Sample rate | 32 kHz | 48 kHz |
| Polyphony | 32 voices | 24 voices, or 32 from the experimental build — see below |
| You can also watch | 16 LEDs as a voice-activity comet, and the 7-segment display | a 720×720p60 DVI visualiser — one tile per voice — plus 8 level LEDs and an encoder |
| To flash it | a clone of this repo and openFPGALoader — one command |
a Chrome tab, and nothing installed |
| Prebuilt bitstream | ✅ boards/basys3/firmware/top.bit |
✅ xls24-r5.tar.gz (slot 7) and xls32-r5.tar.gz (slot 6) |
Neither needs an FPGA toolchain — the bitstream is committed for both, and building from source is optional (§3). Tiliqua is the gentler start: flashing it is a web page, and once flashed it is a standalone instrument. Basys 3 is the cheaper one, and everything reaches it over the single USB cable it is already using for power.
Both boards are driven by the same panel, the same host/ tools and the same 175-case test suite.
The engineering differences between the two shells — clocking, transports, area — are
§5 · The two boards.
Everything the Quick start skipped: the long form for either board, what each cable and jack does, how the panel behaves, and the failure modes — with the reasons, so a symptom tells you what to change.
Neither board needs an FPGA toolchain: both ship a prebuilt bitstream in the repo — a bare
top.bit for Basys 3, a bitstream archive for Tiliqua. Building from source is
§3 and is optional.
To play the synth: Chrome, and that is all. The web UI owns the hardware directly
from the page — it uses Web MIDI
and Web Serial, and neither
ships in Firefox or Safari. In those browsers POWER reports the board as unsupported and
there is nothing to configure — use Chrome. On Tiliqua the flashing is a browser page too
(tiliqua-webflash), so nothing at all has to be
installed; on Basys 3 you need openFPGALoader, one line below.
Phones and tablets can be the UI too. The panel is touch-native — pointer events throughout,
no mouse-only interactions anywhere, and a layout that folds to a single narrow column below
900 px — so an Android tablet running Chrome drives a Tiliqua the same way a laptop does: plug
usb2 into the tablet, open the panel, press POWER. The module takes its power from the Eurorack
case, not from the tablet, so a phone or tablet is a genuinely practical host. A 10-inch screen is
the comfortable size; phone-sized ones fit, but the knobs get tight.
| Host | Flash a board | Send notes | The panel |
|---|---|---|---|
| Mac, Windows or Linux computer + Chrome | ✅ | ✅ | ✅ |
| Android tablet or phone + Chrome | ✅ Tiliqua only | ✅ | ✅ — audio path untested |
| iPhone / iPad | ✗ | ✅ via a Core MIDI app † | ✗ — but CC from that app does the same job |
| A keyboard alone — USB-MIDI, or DIN/TRS into the jack | ✗ | ✅ | ✗ — only the knobs the keyboard itself sends |
| Same computer, but Firefox or Safari | ✗ | ✗ | ✗ |
The crosses are all the same cross: Firefox and Safari ship neither Web MIDI nor Web Serial, and Apple requires every iOS browser to use WebKit, so an iPhone's Chrome is Safari underneath. That is a limit of those browsers, not of the synth — which only ever speaks standard MIDI, and so will talk to almost anything that does.
† Untested with this board. iOS handles class-compliant USB-MIDI and USB audio natively and the module draws its power from the Eurorack case rather than from the phone, so it ought to work; no one has actually tried it.
Two of those crosses are worth a sentence more before you rely on a particular host:
- Basys 3 needs a Mac, Windows or Linux machine. It talks over Web Serial, which only reached Android in 2026 on a limited set of devices, and its 2 Mbaud link has never been tried over one.
- The Android audio path is untested on hardware. It should work, but two things could bite:
Android may label the board's audio input differently than the panel expects, and if its audio
layer downmixes all four USB channels instead of taking the first two, the clock counter carried
on
ch2/3will come through as noise. If you try it, that noise is the symptom to listen for.
For the command-line tools, the demos and the test suite — none of which is needed to play:
uv(Python env + deps):curl -LsSf https://astral.sh/uv/install.sh | shopenFPGALoader(flash over USB-JTAG):brew install openfpgaloader— required for Basys 3, and for Tiliqua only if you SRAM-load your own build instead of using the web flasher (≥ 0.12.1 for the Tiliqua'sdirtyJtagprobe).uhubctl(read USB port state):brew install uhubctl— only forhost/usb_watch.py, the background witness the test suite's diagnosis rules lean on (test/README.md). Worth having before the first odd capture, not after.
Then, once per checkout:
git clone <repo-url> && cd <repo-dir>
uv sync # runtime deps only (all have prebuilt wheels — works on any Mac)Command-line examples below are shown from that project root, and the Python ones run under
uv (pyproject.toml pins the deps).
uv syncinstalls the host tools and the test suite. The web UI needs none of it — it is a static page. Two extras are opt-in:--extra localmidiaddspython-rtmidi, which the host Tiliqua transport needs to send MIDI (it builds from C++ source, so skip it on locked-down machines — e.g. Santa on corp Macs blocks the compiler; the Basys 3 sends MIDI down its UART and the browser has its own Web MIDI).--extra presetgenadds the preset-generation toolchain (dawdreameretc.), only for dev work;--extra deepfitadds the learned audio distances on top of it, and--extra docsthe diagram renderer.
uv syncsyncs the environment to exactly what you name, so--extra localmidion its own uninstalls any other extra you had. Name every extra you want each time — e.g.uv sync --extra localmidi --extra deepfit— rather than adding them one at a time.
You need a Basys 3 board (Xilinx xc7a35t) and a USB cable. macOS ships the FTDI serial
driver, so the board enumerates as /dev/cu.usbserial-* automatically — nothing else to install.
A prebuilt bitstream ships in the repo at boards/basys3/firmware/top.bit,
so you can flash without building (no Vivado / F4PGA — just openFPGALoader). Plug the board in
over USB, then:
# A) Persistent — write the onboard SPI flash (survives power cycles, boots standalone):
openFPGALoader -b basys3 -f boards/basys3/firmware/top.bit
# then set the Basys 3 mode jumper JP1 to QSPI so it loads from flash on power-up.
# B) Volatile — load SRAM directly (quicker, but lost on power-off / unplug):
openFPGALoader -b basys3 boards/basys3/firmware/top.bitVerify it's alive (should print the Artix-7 IDCODE):
openFPGALoader -b basys3 --detect # idcode 0x362d093 / xc7a35Notes:
- SRAM is volatile. After any power-cycle or USB re-enumeration the SRAM config is wiped and the
board goes silent (the web UI shows
frames: 0) — just re-run option B, or use option A so it reloads itself from flash. For a demo machine, prefer A + JP1=QSPI: then it needs onlyuv+ this repo, noopenFPGALoaderand no rebuild. - Power: the board runs off USB. Some laptops (e.g. a MacBook Air over a single USB-C hub) don't supply enough current — if the board's power LED stays dark, use a powered USB hub or the board's external supply.
- JTAG vs UART share the FTDI. Free the serial port before flashing — close the web UI tab (it holds the port through Web Serial) — then reopen it after: the audio stream (UART) and JTAG programming use the same USB chip.
The committed bitstream is the Vivado/DSP48 build: 32 kHz, STAGES=48. To regenerate it see
§3 · Basys 3, then cp build/top.bit boards/basys3/firmware/top.bit.
Rebuilt and verified 2026-08-22 for M37, which put this board back in step with the two DC fixes the Tiliqua had been carrying alone. It closed timing with zero failing endpoints (100 MHz, worst slack +1.322 ns) and then played: A major 7 came back at 438 / 554 / 658 / 830 Hz, all four inside the bin the check can resolve. It took an RTL fix to get there — the engine's audio-ready handshake had become a genuine 10 ns path.
uv run --no-project python scripts/check_artefacts.pyholds its provenance and will say so when it drifts again; the full record istop.bit.md.
Then jump to Run the web UI to play it.
You need a Tiliqua R5 in a Eurorack case with power and one USB-C cable to the dbg port
(JTAG + the bootloader's serial log). That is enough to flash it and hear it. A second cable to
usb2 carries the UAC2 audio and USB-MIDI link — add it when you want to play from the browser
or from the host/ tools, which is Run the web UI below.
boards/tiliqua/firmware/ holds two committed bitstream archives —
each the bitstream plus the manifest the bootloader needs — so you can run the synth without
building. They are the same engine and differ in polyphony:
| voices | die | clk post-route |
slot | ||
|---|---|---|---|---|---|
xls24-r5.tar.gz (raw, 408 KB) |
24 | 93.5 % | 56.63 MHz | 7 | formal — what this repo stands behind |
xls32-r5.tar.gz (raw, 429 KB) |
32 | 98.3 % | 48.37 MHz | 6 | experimental |
Take the 24-voice one. Neither closes the 60 MHz clk constraint — that is
issue #3, and it is why this repo exists
— so both are a bet that the silicon beats nextpnr's model. At 32 voices the bet is 29 % and it
has already failed on one of the two modules the module's maker tried. At 24 it is 6 %, for eight
fewer simultaneous notes. Both archives are graded on hardware here;
boards/tiliqua/firmware/README.md has the numbers and the history.
Then write it to a slot.
A · The web flasher — the default, and nothing to install. Open
tiliqua-webflash in Chrome, pick the module over
WebUSB, and choose slot 7. XLS24 is in the Community list, so for the 24-voice build
there is nothing to download — but that entry is the 54.30 MHz archive, one netlist behind this repo
(see §1 above). To flash what is committed here, or to flash the
32-voice build at all, upload the .tar.gz instead.
B · pdm flash, if you already have the vendor SDK checked out (see
§3 · Tiliqua for what pdm needs). openFPGALoader --scan-usb
should print 0x1209:0xc0ca dirtyJtag apf.audio Tiliqua R5 first — if it does not, the module is
not talking and nothing below will work:
cd ~/Documents/GitHub/tiliqua/gateware
pdm flash archive ~/Documents/GitHub/xls32-fpga-synth/boards/tiliqua/firmware/xls24-r5.tar.gz \
--slot 7Any slot 0–7 works, and the archive does not care which. Slot 7 for 24 voices and slot 6 for 32 is what this repo's docs and tooling assume — keeping them apart means you can A/B the two without reflashing, and slot 6 is where the vendor DSP-MDIFF example used to live. Catch the five-second countdown, pick the slot from the menu once, and every cold boot from then on loads it directly.
Flashing to a slot is also how you avoid the clock trap — take the archive path if you can. The
audiodomain is the SI5351'sclk0wired straight into the fabric, with no FPGA PLL, and only the bootloader programs that chip, from the manifest of whichever slot it last booted. This archive's manifest carriesclk0_hz: 12288000and pinsclk1_hz: 39070000, so booting it from a slot always clocks the module correctly.

0 comments
log in to comment.