XLS32: an FPGA synth,
built entirely by AI agents

A 32-voice polyphonic synthesizer in Google XLS (DSLX) — designed, built, and hardware-verified end-to-end by an AI coding agent through loop engineering.
XLS32 · HLS / FPGA synth Jul 2026 · 50 min
Agenda
  1. 00Who's talking2 min
  2. 01What is XLS326 min
  3. 02The technology stack16 min
  4. 03Loop engineering with AI agents9 min
  5. 04Architecture deep-dive8 min
  6. 05Frictions & learnings5 min
  7. 06Wrap-up + Q&A4 min
  8. AAppendix — the ECP5 portif asked
00
Who's talking
Thirty seconds on me — and on the FPGA synthesizer I built by hand in 2012, which is the same instrument this deck rebuilds without writing a line of it.
Hello
Kazunori Sato
Kazunori Sato
@kazunori_279  ·  Tokyo, Japan  ·  on X since 2008
Developer Advocate, Cloud AI — Google
  • Talks, demos, and a lot of sample code — mostly about AI
  • XLS32 is a personal side project, open-sourced under Apache-2.0
  • github.com/kazunori279/xls32-fpga-synth
The opinions expressed here are my own, not those of my employer.
Me and FPGAs: the 2012 prototype
Fourteen years ago I built this same instrument by hand — an Altera DE0, hand-written Verilog, three blog posts over two weeks.
MIDI, by handDec 15, 2012
A SparkFun MIDI breakout board wired to an FPGA
A SparkFun MIDI breakout into the DE0. 31.25 kbps async serial, oversampled 4× by a Verilog state machine — the first win was 90h 3Ch 40h showing up on the LEDs.
Eight voicesDec 19, 2012
Oscilloscope capture of the 8-voice mixed output
A sine lookup table and a note→step-size table, with eight WaveGen modules passing MIDI down a chain to allocate voices. Summing them clipped 8 bits, so the mix moved to the DAC's 12.
Knobs in the browserDec 29, 2012
Chrome DevTools listing serial ports via chrome.serial
ADSR as HDL counters, one line of Verilog for the VCA — driven over an FTDI cable at 921.6 kbps from a Chrome packaged app, jQuery Knob dials talking to chrome.serial.
Same idea, fourteen years apart. 2012: 8 sine voices, hand-written Verilog, roughly a weekend per feature — and it stopped there. 2026: 32 voices, 4 parts, filters and effects, 130+ scored hardware tests — and I wrote none of it.
01
What is XLS32
Meet the instrument — what it is, and the browser panel driving the real board over USB: oscillators, filter, envelopes, effects, and the 4-part demo songs.
What is XLS32?

A 32-voice, 4-part multitimbral subtractive synthesizer — a literal circuit clocked at 100 MHz that computes one audio sample per tick.

  • Written in Google XLS (DSLX), not hand-written Verilog
  • Runs on a Basys 3 board (Xilinx Artix-7 xc7a35t)
  • Played live from a browser analog-style panel over USB
  • MIDI in, 16-bit stereo audio out — plus I2S DAC and DIN MIDI
  • Open source: Apache-2.0 on GitHub

Because the board was developed remotely, every feature is checked over the USB UART — audio verified on the host by FFT and spectrogram.

XLS32 browser front-end
The web UI driving the FPGA synth live over USB
Demo: the web UI plays the board
Browser UI a static page — no server
Analog-style panel — knobs send MIDI CCs; play via on-screen keys, computer keys, or a Web-MIDI controller. The tab owns the port and byte-aligns the audio stream itself
▼ MIDI bytesWeb Serial · USB UART · 2 Mbaud16-bit stereo ▲
Basys 3 board the synth circuit
Flashed with the prebuilt firmware/top.bit — the FPGA computes every sample; no toolchain needed to demo
Demo video — the web UI driving the board, with the synth's own audio
youtu.be/2ROr9M_ZlVY
Two pieces played end to end by the board — 4 parts each, its own audio:
Bach · Prelude in C4 parts · 1:51 Saint-Saëns · Le Cygne4 parts · 2:18
Not a single line of it
was written by hand — and every feature is verified by machine The whole design was built by Claude Code (Opus 4.8) through a self-verifying build → measure → revise loop, over a network, with no human watching the board — and almost all features were developed from a smartphone, during a one-week trip.
02
The technology stack
Why put a synth in FPGA fabric — and the three technologies that make it possible: the board, the language, and the toolchain.
Subtractive synth 101
Start with a harmonically rich wave, then carve away. Every block on this slide exists as a circuit in XLS32.
Oscillators
Generate raw, harmonically rich waves — saw, square, triangle, noise. Detune and unison thicken the sound.
Filter
The "subtractive" part: a resonant filter carves the spectrum — sweep the cutoff and the timbre moves.
Amplifier
A D S R
An ADSR envelope (attack · decay · sustain · release) shapes loudness — pluck, pad, or organ is mostly this curve.
Effects
Chorus, delay, reverb add width and space — the difference between a tone and an instrument.

Modulation makes it move: envelopes and LFOs wiggle pitch, cutoff, and amplitude. A patch is just a setting for every block — in XLS32, each one is a MIDI CC.

Other synthesis families: sampling · wavetable · FM · additive · physical modeling · analog modeling (virtual analog). XLS32 is subtractive at heart — with a taste of FM from its cross-oscillator mod.

The synth, by the numbers
SpecNotes
Polyphony32 voices, time-multiplexed — one voice per proc tick (~24 engine cycles)4-part multitimbral (MIDI ch 1–4), shared dynamic voice pool
Oscillators2 per voice + sub-osc → up to 64 across the 32 voices5 waveforms, PWM, cross-osc ring / FM / FM+ (8 ratios)
FilterPer-voice resonant state-variable — LP / HP / BP / notchKey-tracking + filter envelope + LFO cutoff modulation
Modulation2× ADSR per voice, per-part LFO, bend, portamento, unisonAll controlled over MIDI CC — the web UI is just CCs
EffectsStereo chorus, ping-pong delay, 8-comb Freeverb reverbBlock-RAM delay lines in the Verilog shell
Sample rate32 kHz · 16-bit stereo PCM over USB UARTVivado / DSP48 backend; open backends run 28 kHz
Verification130+ scored end-to-end hardware tests over USBFFT / spectrogram graded 0–100, report video generated
How synths are built
Five ways to make an oscillator — ordered from easiest to hardest to build.
Web Audio
Browser toys & education — Chrome Music Lab, online demos
+ Zero install, runs anywhere, a few lines of JS
− Best-effort timing: ~10–40 ms buffers, glitches under load
Software synth
Most music production — Serum, Vital, DAW instruments
+ Rich features, fast iteration, huge ecosystem
− Shares the CPU with the OS; latency = the audio buffer
Discrete analog
Moog & Eurorack — op-amps, VCOs, real voltage
+ The analog sound, feel, and hands-on control
− Hand-built electronics; drift, tuning, one voice costs real money
this project
FPGA
Hybrid hardware synths — Novation Peak, UDO Super 6
+ Hardware determinism, jitter-free, reconfigurable
− HDL + timing closure: the hardest dev loop (what this talk attacks)
ASIC
Classic chips at scale — Yamaha DX7 FM chips, the C64 SID
+ Cheapest per unit, lowest power, fully custom
− Millions in NRE, months per spin, no do-overs after tape-out
Easiest to buildHardest to build

XLS32 takes the FPGA path — hardware guarantees without ASIC economics — and attacks its weakness: the development loop.

Why an FPGA, not Web Audio?
Deterministic, tiny latency

A fixed-length pipeline builds every sample on the same schedule — delay through the datapath is a fixed handful of microseconds: sub-millisecond and jitter-free.

Web Audio renders 128-sample blocks on top of a ~10–40 ms OS buffer, sharing the CPU with the UI and the garbage collector.

Hard-real-time datapath

32 voices time-multiplex through one pipeline — all finished inside every sample tick. The per-sample work is a fixed cycle budget that always completes, regardless of the patch.

A CPU's timing is best-effort and degrades as polyphony and load grow.

Customizable to the bit

Arbitrary fixed-point widths, a bespoke oscillator/filter topology, sample-accurate modulation routing — and the same design drives a DAC or hardware MIDI with no OS/driver round-trip.

It's a real instrument, not a browser tab.

FPGA 101
A field-programmable gate array — a grid of logic you wire up into any digital circuit, then rewire by reflashing.
I/O ring — UART · JTAG · LEDs · Pmod pins 1011 LUT fabric (logic) Block RAM stripe DSP stripe 1011→ bitstream
LUT fabric

Thousands of look-up tables + flip-flops; programmable routing wires them into any logic.

DSP slices

Hardened multiply-accumulate blocks in vertical stripes — fast math off the fabric.

Block RAM

Dedicated on-chip memories, also in stripes — buffers and tables, with clocked reads.

1011 The bitstream

The "program": a circuit description loaded at power-up — your design becomes the chip.

XLS32's Artix-7 xc7a35t: 20,800 LUTs · 90 DSP slices · 36 Kb × 50 block RAMs · one 100 MHz clock — the synth is a literal circuit computing one audio sample per tick.

Xilinx Artix-7 XC7A35T on a PCB
The exact chip: an Artix-7 XC7A35T on a board — photo by Pedant01, Wikimedia Commons, CC BY-SA 3.0
Where FPGAs run in the real world
Anywhere the deadline is set by physics and latency is measured in micro- or nanoseconds.
Subaru Levorg (2nd generation)
Automotive

The Subaru Levorg's EyeSight ADAS: stereo-camera vision on a Zynq UltraScale+ — one FPGA-SoC per car, across millions of Subarus.

Subaru selects Xilinx — press release ↗

Photo: Tokumeigakarinoaoshima, Wikimedia Commons, CC BY-SA 4.0

DiGiCo SD5 digital mixing console
Audio & video

DiGiCo's SD consoles mix on Stealth "Super FPGA" engines — up to 256 audio paths per console at ~1–2 ms latency.

DiGiCo Stealth Core 2: FPGA processing ↗

Photo: The Blackbird Academy, Wikimedia Commons, CC BY-SA 2.0

F-35 Lightning II variants in flight
Aerospace & defense

F-35 avionics: Lockheed Martin buys Xilinx FPGAs by the tens of thousands across the fleet — deterministic radar and sensor processing.

Tens of thousands of FPGAs — Military Aerospace ↗

Photo: U.S. Air Force / SSgt. K. Slivinske, public domain

AMD Alveo UL3524 accelerator card
High-frequency trading

Market makers and prop traders run the tick-to-trade path in FPGA logic — parse the feed, fire the order, wire-to-wire in under a microsecond.

AMD's purpose-built trading FPGA — press release ↗

Image © Advanced Micro Devices, Inc., amd.com

Different industries, one reason: when the deadline is physics, the logic goes into hardware. XLS32 borrows the same superpower for music.

Verilog and the standard dev flow
A hardware description language: you describe the circuit register-by-register, wire-by-wire, clock-by-clock.
Write RTL
always @(posedge clk)   q <= d & en;
Registers, wires, always blocks — plus pipeline stages, handshakes, and bit widths, all managed by hand.
Simulate
clk q
A testbench drives the design (iverilog, Verilator) — cycle-accurate, but only as good as the testbench you wrote.
Synthesize
yosys / Vivado map the RTL onto LUTs, DSPs, and BRAMs — a netlist of real chip primitives.
Place & Route
Fit the netlist onto the die and check timing — the slow step: minutes to hours per iteration.
Flash & test
1 0 1 1
Program the bitstream over JTAG and verify on real hardware.

Fail timing and you're back to editing RTL. This loop is the iteration cost that XLS32's whole methodology — HLS plus cloud builds plus machine-graded verification — is built to shrink.

Google XLS 101: hardware as software
Google XLS logo
  • High-Level Synthesis (HLS): compile a software-style description of behavior into a hardware circuit — the compiler writes the RTL for you (Wikipedia ↗)
  • XLS is Google's open-source HLS toolkit; you write DSLX — a Rust-like language of pure functions and small stateful procs
  • Scheduling, pipeline-register insertion, and bit-width narrowing are automatic
  • Unit tests run in milliseconds in the interpreter — no testbench, no simulator, no build

Most iterations never touch the FPGA tools at all — that's what makes the agent loop fast.

What you want to build
A synth voice: oscillator → filter → envelope — as math
ƒ
DSLX — describe the behavior
Pure functions and procs; unit-test in milliseconds
codegen
D Q
Verilog — the compiler emits RTL
Scheduled, pipelined, bit-width-narrowed — for you
synth + P&R
Gates on the chip
Synthesis + place & route map it onto LUTs, DSPs, BRAM
From synth.x to engine.v
The same oscillator logic, before and after the compiler — abridged from the real code.
synth.x — what you write (DSLX)
// One oscillator sample: turn the phase into a wave.
// Bit-widths are types: u3/u32 unsigned, s16 signed wires.
fn voice_wave(wave: u3, phase: u32,
              noise: s16) -> s16 {
    let t = phase[24:32];  // cycle position 0..255
    match wave {
        u3:0 => SINE[t],                        // sine
        u3:1 => (t as s16) * s16:16 - s16:2048, // saw
        u3:4 => noise,                          // noise
        _    => SINE[t],
    }
}

+ Write pure functions, as usual — code reads top-to-bottom, composes, and unit-tests like software.

+ The tool does the state management and scheduling — which value lands in which register on which cycle (48 stages here) is the compiler's problem.

− Where cycle-exact control matters (BRAM ports, I/O), you still drop to the Verilog shell.

engine.v — what the compiler emits
// pipeline registers p0/p1/p2 — inserted for you
always @(posedge clk) begin
  p0_t   <= phase[31:24];

  p1_sin <= SINE[p0_t];        // 256-entry ROM
  p1_saw <= {p0_t, 4'h0}
            - 16'd2048;        // shift & offset

  p2_out <= (wave == 3'd0) ? p1_sin
          : (wave == 3'd1) ? p1_saw
          : p1_noise;          // wave-select mux
end

+ Full control over every register, port, and clock cycle — when you truly need it.

Everything happens in parallel: every always block fires on every clock, all at once — nothing reads top-to-bottom.

It's all state management and scheduling, by hand: which value is in which register on which cycle is your problem — the classic wall for software engineers.

DSLX to bitstream — the build pipeline
DSLX
fn voice_wave(wave: u3,   phase: u32) -> s16 {     … }
synth.x — the whole engine in one 378-line proc. Unit tests run in milliseconds via the interpreter.
XLS codegen
ƒƒ logic between pipeline registers
ir_converter → opt → codegen with --generator=pipeline, 48 stages → engine.v.
Fix-ups
- end else begin + end else if (ce) begin
fix_verilog.py injects the global clock-enable and unrolls generate loops the open flow rejects.
Synth + P&R
yosys → VPR (F4PGA), nextpnr (openXC7), or Vivado — selected by BACKEND=.
Flash
1 0 1 1
openFPGALoader over JTAG — SRAM for iteration, SPI flash for standalone boot.

One command end to end: STAGES=48 WCT=48 scripts/remote_build.sh — push sources to a GCE VM, build, pull back top.bit plus the timing report. Never trust a build you haven't measured.

03
Loop engineering
with AI agents
This whole board was brought up remotely and headlessly — no one watching LEDs, listening to a speaker, or pressing buttons.
"Design a tight, self-verifying edit → build → run → observe cycle, and let the agent iterate inside it."
Loop engineering
instead of hand-prompting each step
The loop
Give the agent an objective pass/fail, and the loop runs unattended.
Edit
The agent changes synth.x (DSLX) — new feature, fix, or timing tweak.
Build
Bitstream on a native x86 GCE VM, ~6 min — timing report teed back with it.
Flash + drive
openFPGALoader over JTAG, then MIDI in / audio captured over the same USB cable.
Verify
FFT / spectrogram scored 0–100. Pass → milestone done. Regression → back to Edit.
The load-bearing ingredient: autonomous verification

Every feature emits a signal a machine can grade without human senses:

  • Audio is teed out over USB as a sample stream
  • Pitches verified by FFT — a chord is N simultaneous peaks
  • Timbre and stability verified by spectrogram — haze, clipping, and dropouts are obvious
  • 130+ scored e2e tests: basic features, combinations, stress — each 0–100, failing on regression

Lesson: a single FFT peak-check once passed while the audio was corrupted. Render the whole capture as a spectrogram, not one clean slice.

Filter sweep spectrogram
A cutoff sweep, verified as a spectrogram — the machine reads this, not a person
A loop is only as good as its cycle time
FPGA place-and-route is the slow step — so the build was moved to the cloud.
Apple Silicon MacF4PGA under x86 emulation (Docker)
~10 min
Native x86 GCE VMscripts/remote_build.sh — sources up, bitstream + timing back
~6 min
DSLX unit teststhe inner loop — most iterations never need a build
ms

The agent gets its verdict sooner and fits more iterations into an hour — and the laptop stays free. Prototype in a software model first; spend builds only to confirm.

Built in verifiable increments — M1 to M19
M1M3M6aM6bM13–14WEB UIM19
First soundOne DDS sine + ADSR, verified over UART from afar.
Real MIDI inUART RX + parser + voice allocation; FFT confirms the pitches.
Pipelined engineRewrite: time-multiplexed 32-voice proc — one voice per proc tick.
Per-voice filterEvery voice gets its own resonant SVF + key-tracking.
BRAM effectsChorus, ping-pong delay, and Freeverb reverb in block RAM.
Browser panelSerum-style UI, presets by inverse synthesis (CMA-ES).
Cross-osc FMRing / FM — inharmonic bells; the de-latch fix hardens the SVF.
What each milestone actually sounded like
Every clip is a capture off the Basys 3 — the picture is the spectrogram of the audio you are hearing. Click a clip to play it.
M1 · first sound0:06
One DDS sine plus a linear ADSR at 8-bit / 4 kHz. Thin — but it proved the edit → build → flash → measure loop was closed.
M6b · per-voice filter0:10
After the pipelined rewrite every voice gets its own resonant SVF — hear the cutoff sweep open and the resonance start to sing.
M9 · noise + sub-osc0:13
Three cheap analog staples at once: LFSR noise, a multimode filter, and a sub-oscillator an octave down.
M13 · chorus + delay0:13
First use of block RAM — a 16K×16 delay line drives interpolated chorus taps and a ping-pong echo. Mono becomes wide.
M14 · reverb0:11
A Schroeder / Freeverb tail in BRAM — feedback combs plus all-passes, room through cathedral. Its multiplies forced the ÷4 clock.
M15 · unison0:13
Voice-stacking — 2 / 3 / 4 detuned, phase-decorrelated voices per note. A thick super-saw, paid for with max polyphony of 32 ÷ N.
…and what it sounds like once the machine grades it
The same capture rig, pointed at the finished instrument.
e2e test report4:41
The agent's own regression run as a captioned spectrogram: 52 tests over USB — basic, integration, and strict stress — each re-flashed, re-recorded, and scored PASS / WARN / FAIL against an FFT reference, with one overall grade on top.
web UI · Bach Prelude0:43
Four-part multitimbral playback driven live from the browser panel — Bach's Prelude in C, 32 voices shared across the parts, one Artix-7 doing all of it.
Nineteen milestones, and not one of them was signed off by ear alone — behind every clip in this section there is a number the agent had to beat.
04
Architecture deep-dive
One clock, one sample rate. Everything is either a pure function or a small proc — and the synth emits one audio sample per tick.
One clock, three cadences
CadenceRatePeriodWhat happens
Master clock100 MHz10 nsEverything on the board — one oscillator, no MMCM/PLL
ce — the engine clock33.3 MHz (÷3)30 nsEvery pipeline register in engine.v is gated by this one enable. One voice takes ~24 of them.
ce8 (effects)16.7 MHz (÷6)60 nsOne step of the 28-state effects FSM — a BRAM read+write each
Sample period32 kHz31.25 µsOne 32 kHz stereo sample — 3,125 master clocks
clk ce ce8 30 ns · one engine cycle — nothing in engine.v moves except on this edge

The 32-voice scan takes ~2,304 of 3,125 clocks per sample — 74% of the period. The effects pass (168 clocks) and the UART frame (~2,000 clocks) run concurrently with it, so the compute binds first, not the UART — leaving ~26% headroom.

A patch is 28 numbers — and those numbers are the tone
The circuit never changes. Hand it a different 28 numbers and the same silicon becomes a bass, a pad or a bell.
one bare oscillator — no character the patch — 28 numbers a tone: attack, closing filter, decay Each number is one knob. None of them is a sound on its own — the tone is what they do together. Below: the seven jobs they split. oscillator CC70·75·73·78·80 the raw shape the note starts as pick one of fiveone wave, detuned + doubled wave pw subsel detsel unison cross-mod CC85·86·87 one oscillator bending the other osc 1 × osc 2 xmode xdepth xratio filter CC74·71·72·79 which harmonics survive buzzy incutoffmellow out cutoff reso fmode fdepth LFO CC76·77·1·92 a slow wave that keeps things moving pitch — vibratopulse widthfilter cutoffvolume — tremolo lfo_rate lfo_depth lfo_ph vibsel trdep amp ADSR CC20-23 how loud it is, moment to moment ADSRswells, then fades a_att a_dec a_sus a_rel filter ADSR CC24-27 how bright it is, moment to moment ADSRthe cutoff sweeps up f_att f_dec f_sus f_rel performance CC7 · bend · CC5 what your hands add on top volbendportsel vol bend portsel
One sample = the patch × the voice's 189 bits
The amber glyphs are the same 28 numbers you just saw — here is where each one lands. The blue side is the voice's own state, remembering where this note has got to.
the voice's 189 b — where this note is right now the patch — the 28 numbers from the last slide cinc 26 b · pitch step phase 32 b · osc 1 angle ph2 32 b · osc 2 angle subhi 1 b · sub state env 19 b · amp env + stage fenv 19 b flo 19 b fbnd 19 b 1 pitch step inc = cinc « 6 ± bend, vibrato ± unison detune → inc 2 accumulate phase += inc wrap = one cycle → phase′ 3 osc 2 / mod ph2 += inc × ratio mod = sin(ph2) → mod 4 pick the wave 5:1 3:1 saw sq tri sin nz dry / ring / FM → o12 5 sub-osc ±1800 square, one octave down → w 6 VCA g = env · vel · trem amp = w × g → amp 7 state-variable filter flo fbnd 4:1 f = cutoff + key·note + fenv·fdepth + LFO flo, fbnd: 3 integrator steps → filt 8 one sample t this voice, this tick → into the mix portsel bend vibsel unison detsel xratio xmode xdepth wave pw subsel a_att…a_rel vol trdep cutoff reso fmode fdepth f_att…f_rel 167 b of that is rewritten every cycle; the other 22 b (note · vel · uni · part) are latched at note-on and just ride along. Same silicon for all 32 voices. What differs is 189 bits of state and which patch it points at. Turn a knob and the amber side changes. Press a key and the blue side starts moving. The output sample is just where the two meet this cycle.
Inside the 48 stages
Nobody drew these boundaries — XLS cut the dataflow graph into 48 slices. One box per stage, drawn to the size of the logic inside it; the bar between two boxes is the register wall that hands the data on.
box area = combinational logic in that stage · bar between boxes = the registers handed forward 0 4 8 12 16 20 24 28 32 36 40 44 47 data moves one stage per clock-enable — 30 ns, one in every three 100 MHz cycles — so all 48 stages take 1.44 µs end to end a new voice enters every 24 ce (measured), so two are inside the 48 stages at any moment · ● = a hardware multiply, mapped onto one of the 26 DSP48 blocks and so costing no LUTs 0–1 MIDI in A UART byte, then a running-status MIDI parse: note-on, note-off, control change. 2–20 Free-voice scan A serial fold over the 32 slots looking for free ones: the count chains slot to slot, so only ~2 fit per stage. 21–22 Voice & part fetch One-hot write masks, then the slot-0 voice and its part’s parameters — 93 array reads land in stage 22 alone. 23–27 Envelopes & modulation Two ADSRs, portamento glide, the LFO, tremolo gain, the note→increment table, unison detune seeds. 28–33 Oscillators Phase advance, detuned 2nd osc, sub-osc, noise LFSR, PWM compare, ring/FM multiply, and the writeback into the 32-voice ring. 34–38 Filter The state-variable filter: three clamped integrator steps (low → high → band) plus the de-latch leak. 39–41 Mix & output Per-part gain and volume, accumulate into the mix, clamp to 16 bits, advance each part’s LFO on slot 31. 42–47 Nothing Six stages carrying only the valid bit: latency the scheduler added to reach the 48 we asked for. This is nobody’s block diagram. It is lumpy, it interleaves note handling with DSP, and it ends in six empty stages. We only ever gave a number — --pipeline_stages=48. Where the cuts fall is the scheduler’s answer to the 30 ns budget, not a partition we drew.
One pipeline, 32 voices taking turns
Top: where each voice sits in the hardware. Bottom: the same run on a time axis — one playhead drives both. It is 48 stages deep, but a new voice enters only every 24 cycles, so two are ever in flight and the other 46 stages carry bubbles.
Animation: voices advancing one stage per engine cycle through the shared 48-stage pipeline, with the matching Gantt chart filling in below.
Why a ring? The 32:1 mux was the timing wall
A dynamic array index is not free in hardware. It is a mux, and this one was the widest thing on the chip.
✗ Index the array voices[vidx] voice 0 voice 1 voice 2 voice 30 voice 31 32:1 mux vidx to the datapath A mux with 6,048 input bits (189 b × 32) ≈21 ns — over a 30 ns budget. Fails timing. ✓ Rotate the array voices[0] slot 0 slot 31 cycle N v0 v1 v2 v3 v4 v5 v6 v7 v8 v9 v10 v11 v12 v13 v14 v15 v16 v17 v18 v19 v20 v21 v22 v23 v24 v25 v26 v27 v28 v29 v30 v31 cycle N+1 v1 v2 v3 v4 v5 v6 v7 v8 v9 v10 v11 v12 v13 v14 v15 v16 v17 v18 v19 v20 v21 v22 v23 v24 v25 v26 v27 v28 v29 v30 v31 v0′ process_voice() the one shared datapath rotate_in(v0′) every slot shifts left by one The index is the literal 0 — the read and the write become plain wires. rotate_in() only uses loop-constant indices, so XLS unrolls it into a fixed shift. No mux is built at all.
Three effects, one primitive: a delay line in block RAM
Chorus, echo, and every comb and all-pass in the reverb are the same ring buffer. They differ only in how far behind the write pointer they read — and what they write back.
BRAM 16K × 16 waddr newest sample in waddr − D the tap, D samples back D = the delay y[n] = x[n − D] Nothing is ever shifted. Only the two pointers move — one slot per sample. 4 × 16K×16 = 32 block RAMs zeroed at power-up first Chorus CC94 depth LFOthe tap slidesQ3, interpolated DELAY D short and moving — 300…556 samples, swept by a triangle LFO, L/R anti-phase WRITTEN BACK nothing. It only reads — it rides along on the buffer echo is already writing. Echo CC82 time · CC95 depth LRLRL× ½each repeat landson the other side DELAY D long and fixed — 128…16,256 samples, ~4…508 ms, set by CC82 WRITTEN BACK the input + ½ × the OTHER channel's delayed sample — so it ping-pongs L↔R Reverb (Freeverb) CC91 size · CC93 wet 8 combsΣ4 all-pass in serieswet DELAY D many short fixed taps — 8 combs at 810…1230, 4 all-pass at 163…403 (R +23) WRITTEN BACK the input + damped feedback (comb), or the all-pass butterfly. Room size = g. One 28-state FSM walks the tank — one BRAM read + write per step, on ce8 echo + chorus dst 1 L: 8 combs dst 5 L: 4 a-pass dst 13 R: 8 combs dst 17 R: 4 a-pass dst 25 28 One arithmetic datapath, time-shared L then R — 28 × 6 = 168 of the 3,125 clocks in a sample, so serializing the two channels is free.
The life of one sample: 31.25 µs, 3,125 clocks
32 kHz means a new stereo sample every 3,125 master clocks. Here is where they actually go.
The period — one 32 kHz sample: 3,125 clocks of the 100 MHz master, drawn to scale sample N+1 — being built · the engine starts it the instant N is pulled, and needs ~2,300 clocks sample N — being shipped · the engine finished this one last period; the tick only pulls it out engine 32 voices · ~24 ce each effects FSM 28 steps · 1 per ce8 UART TX 4 bytes @ 2 Mbaud avld high, waiting for the tick — ~820 clk dst back to 0 — nothing more to do this period ~960 clk — TX lane idle ~2,300 clk (32 voices × ~24 ce × 3) 168 clk (28 steps × 6) ~2,000 clk — 4 bytes × 10 bits × 50 clk per bit 0 1,000 2,000 3,125 clk · 31.25 µs MIDI in sits outside this picture — bytes are taken whenever they arrive, on the next ce, ~500 clocks each Clock by clock — the same period around the tick; the long runs above are snipped at ‖ clk (100 MHz) ce (÷3, engine) ce8 (÷6, effects) sample ready (avld) 32-voice scan (on ce) stick — 32 kHz pulse audio pull (ardy, on ce) effects FSM (dst, on ce8) dst 1→28 UART TX out TX The engine runs one sample ahead: effects work on N while the engine is already building N+1. They are separate blocks, so the overlap costs nothing: the tick hands N to the effects FSM and the same handshake starts the engine on N+1. The engine's ~2,300-clock scan is the term that binds.
The division of labor: DSLX vs the Verilog shell
ƒ Pure DSP math → DSLX
  • Oscillators, SVF, ADSR, LFO, unison, mixer — the compiler's pipelining and bit-width narrowing pay off
  • Unit-testable in milliseconds, no handshakes to manage
Timed memory + control → Verilog
  • Block RAM needs a synchronous read; XLS emits async reads that never infer to BRAM
  • The effects are memory-port scheduling, not pipelined math — a 28-state FSM time-shares one multiplier, L then R
The effects chain
  • Mono engine; the shell builds the stereo image — anti-phase chorus, ping-pong echo, 8-comb Freeverb with the +23-sample stereo spread
  • Four 16K×16 circular buffers = 32 block RAMs — the chip's binding resource
Where it lands on the chip
Committed Vivado build, Artix-7 xc7a35t — from report_utilization.
Block RAMthe binding resource
65%
Slice LUTs
50%
Registers
42%
DSP48 slices26 of 90 — every × off the fabric
29%
Resource floorplan
Engine + shell in CLB fabric, multiplies in DSP48, delay lines in BRAM
Three interchangeable P&R backends
BackendDSP48BRAMClock / rateNotes
Vivado✓ 26✓ 32÷3 · 32 kHz The shipped bitstream — ~18.5 ns critical path, ~10 ns margin. Closed-source.
openXC7÷4 · 28 kHz Fully open, real Fmax report — but nextpnr can't route the DSP's CARRYCASCIN pin (yet).
F4PGA / VPR÷4 · 28 kHz Fully open; soft multipliers, slice-bound ~90%. Where this project started — and what shaped the design.

XLS was never the blocker — the P&R backend was. The identical RTL infers 26 DSP48 + 32 BRAM on Vivado, halving the critical path (~40 → ~18.5 ns) and restoring true 32 kHz.

The factory presets were searched, not dialled in
128 a bank, and nobody turned a knob: each one is a CMA-ES run over the 23-dimensional CC space.
Seed per-category start Render engine.py — synth.x in NumPy Loss one number: distance to target CMA-ES 23-dim vector, 13 per generation Best patch → 128 per bank, as JSON Target one recorded note — NSynth or a GM soundfont a new population of candidate patches — about 800 renders per target Inside the Loss box — both sounds are turned down to the same loudness first, so only their character is compared, never how loud they are. Three scores, added up What it is made of multi-res STFT ×1 each Which pitches and overtones are in it — looked at both close up and zoomed out, so nothing slips past. Its overall colour mel-ish bands ×2 The same view, blurred into 24 wide bands: bright or dark, thin or full. One overtone off is not a miss. Its shape in time amplitude envelope ×3 How the note swells and fades away. Without this, a slow pad could score as a match for a short pluck. Inside the CMA-ES box — drawn in 2-D, though the real search has 23 axes · the rings are the loss landscape and × is the target sound 1 · Sample A generation is drawn from an ellipse — its centre is the best guess so far, its shape is how far to explore each axis. 2 · Rank and cut Render and score all 13, keep the better half. Nothing is ever differentiated: the loss is evaluated, never inverted. 3 · Move and reshape The centre moves onto the survivors and the ellipse stretches that way. 62 generations of 13 makes up the ~800 renders. Close on the spectrogram is not the same as good to the ear — which is where this stops. Medians of 22.9 and 28.5 look fine on a scale where identical ≈ 0 — but a score like this cannot say what a good sound is, and a subtractive core cannot make brass at all.
05
Frictions & learnings
Three tools that don't know about each other, stacked. Every seam leaks — here's what actually bit, and what it taught.
The open flow's hard limits shaped the whole design
No DSP48 inference
  • Every multiply becomes a LUT+carry soft multiplier → keep multipliers tiny: narrow operand types so XLS's narrowing pass shrinks them
No BRAM from async reads
  • XLS ROMs stay giant muxes; only a hand-written sync-read RAM in the shell maps to block RAM
No MMCM, no clock dividers
  • You can't make a slower clock → run everything at 100 MHz and advance on a global clock-enable (the multicycle trick)
Timing must be reasoned
  • The tools can't see the multicycle and report those paths as failing — tee the real report, measure every build, and leave margin: placement is noisy
Fixed-point warfare — the bugs floating-point sims never show
The filter that latched

At high resonance the SVF state sticks on the clamp rails — silence. Under bright polyphonic FM it locked into a full-scale limit cycle, 96% of playtime railed.

Fix: leaky integrators (~1%/sample) pull the poles inside the unit circle — any self-oscillation decays. The float sim never showed it; only hardware did.

The reverb that ran away

A small-step damping shift crushed the audio band and could wrap the 16-bit state — sign flip → runaway feedback.

Fix: damping as (old+new)/2 — inherently overflow-safe, band preserved. And clear the BRAM at reset: power-up garbage seeds the loop.

Saturate, never wrap

A wrap is a huge discontinuity — a broadband click. The mixer clamps; demos keep big chords at moderate velocity.

And verify with a spectrogram of the whole capture — one clean FFT slice passed while the audio was actually corrupted.

What made the agent loop actually work
Objective pass / fail
  • Every feature graded 0–100 by machine — no "sounds about right". The agent reads the number and revises
Prototype before you build
  • A NumPy/numba model of the engine mirrors the RTL — FM strength, reverb damping, and preset search were all proven in sim first, then spent a 6-minute build
Calibrate sim to silicon
  • A probe compares the same patches on sim and board — matched presets are sim-optimal until board-validated; captures are nondeterministic, so best-of-N and reflash
When the format changes, audit every consumer
  • The board went stereo; the host still read mono — every tone an octave low, and a relative pitch check hid it
06
Wrap-up & Q&A
Takeaways, where to find everything — and your questions.
Takeaways
  1. 01HLS makes DSP hardware feel like software — with a thin shell where control matters
  2. 02Agents can build real hardware when every feature emits a machine-gradable signal
  3. 03Cycle time is the leverage: fast builds + a software model = more iterations per hour
  4. 04Measure, don't trust — timing reports, spectrograms, and sim↔board calibration
  5. 05The seams between tools are where the friction lives — log them; they're the reusable asset
Resources
The project
  • github.com/kazunori279/xls32-fpga-synth — Apache-2.0, prebuilt bitstream included
  • Demo video: youtu.be/2ROr9M_ZlVY
Docs in the repo
  • ARCHITECTURE.md — per-block deep-dive: code, dataflow, timing charts. ARCHITECTURE_tiliqua.md for the ECP5 shell
  • DEVELOPMENT.md — milestone history + the toolchain friction logs. DEVELOPMENT_tiliqua.md for the port
The stack
  • Google XLS — google.github.io/xls
  • F4PGA — f4pga.org · openXC7 · yosys + nextpnr · openFPGALoader
  • Basys 3 — digilent.com · Tiliqua — apf.audio

Thank you

Questions?

A
Appendix
The ECP5 port
The same engine is also a Eurorack module. Everything from here is the second board — what the shell had to become, and which numbers moved when the same source was asked to fit a smaller die.
…and the same engine is also a Eurorack module
From here on this is one design on two boards. Where the numbers differ, both are given.
Basys 3 — the development board

Digilent Basys 3, Xilinx Artix-7 xc7a35t. 32 voices; audio and MIDI both travel down one USB UART and are graded on the host by FFT. Where the project started, and where an engine change is still measured first.

Verilog shell · Vivado builds the shipped bitstream.

Tiliqua R5 — the instrument

apf.audio Tiliqua, Lattice ECP5 LFE5U-25F. 24 voices on a smaller die, out of Eurorack jacks through a real codec, TRS MIDI in, and a 720×720p60 DVI visualiser. Class-compliant USB audio, so any DAW sees it without a driver.

Amaranth shell · fully open yosys + nextpnr-ecp5.

Nothing in synth.x knows which board it is on. Retargeting to the ECP5 was recompiling the same source at a different --pipeline_stages — 48 down to 12 — not a rewrite. That is the dividend of letting a compiler schedule the pipeline instead of placing it by hand.

Shared, unchanged: one generated engine.v · one browser panel · one host/ toolchain · one 175-case scored suite. Which board it is pointed at is a flag.
Two shells, one engine
The generated engine.v is the same logic on both boards. Every number that differs lives in the shell around it.
Basys 3 · Artix-7Tiliqua · ECP5
Shell & P&RVerilog rtl/top.v — Vivado, F4PGA or openXC7 Amaranth gateware/ — yosys + nextpnr-ecp5, fully open
Engine clock100 MHz, engine on a ÷3 clock-enable 12.288 MHz — an SI5351 wired straight into the fabric, no FPGA PLL
Pipeline depthSTAGES=48 → 768 cycles per sample STAGES=12 → 224, because that clock only allows 384
Sample rate32 kHz — the shell pushes samples the codec pulls: 48 kHz of demand through a 3/2 resampler lands on the engine as exactly 32 kHz
Audio & MIDI16-bit PCM and MIDI over one 2 Mbaud UART Eurorack jacks via an AK4619 · UAC2 audio + USB-MIDI on one USB-C · TRS MIDI in
Multiplies26 × DSP48E1, of 90 27 × MULT18X18D, of 28 — all but one on the die
You can watch16 LEDs as a voice-activity comet, 7-segment 720×720p60 DVI — one tile per voice, no framebuffer

Reshaping the multiplies for the ECP5's narrower 18×18 tiles made the Basys 3 build cheaper too: 78 fewer LUTs, a 0.32 ns shorter path, the same 26 DSP48 — and 3,000 audio samples bit-identical. Porting paid a dividend backwards.

Four clock domains, and the engine's is not the FPGA's
Basys 3 has one oscillator, no PLL, and a ÷3 enable. Here the engine's clock arrives from an SI5351 on the other side of the board, and the FPGA never divides it.
DomainRateSourceWhat runs in it
audio 12.288 MHz SI5351 clk0, straight into the fabric The XLS engine, its boot ROM and the visualiser tap
sync + usb60 MHz ECP5 PLL off the 48 MHz oscillator — and the ULPI PHY fixes it there Everything else: MIDI, the effects, the codec, the USB tee, luna's whole stack
dvi (+dvi5x)39.07 MHz SI5351 clk1 → the second ECP5 PLL The voice tiles and the TMDS PHY
fast120 MHzECP5 PLL SDK infrastructure — unused by this design
Run it in sync at ÷1
60 MHz, one domain, nothing to cross.
Not available. No STAGES reaches 60 MHz — the best measured is 59.2 at 48, and that costs 70% of the device's flip-flops.
…with a clock enable
What Basys 3 does: gate every register ÷3.
Not available either. An enable does not relax a register-to-register path. Vivado is told the path is multicycle; nextpnr-ecp5 has no equivalent.
built
Give it a clock of its own
12.288 MHz, from a part already on the board.
STAGES=12 closes at 27.5 MHz and needs 7.2 — 1.7× margin, and the engine runs on every edge.

Two AsyncFIFOs are the whole crossing — 4 deep for MIDI in, 8 deep for audio out. The clock-enable port fix_verilog.py injects for the Basys 3 is still in this Verilog, because it is the same Verilog; here it is tied high and dead.

The same source, scheduled into 12 stages instead of 48
--pipeline_stages is a codegen flag, not a source change — and it is the only knob that had to move for the engine to fit a 12.288 MHz clock.
Engine cycles to compute one 32 kHz sample 384 — every cycle a 12.288 MHz clock has to give STAGES=48 768 — fine on Basys 3, where 3,125 clocks are on offer STAGES=12 224 at 32 voices shipped ~168 at 24 voices — 43.8% of the period
Why not one cycle a voice
  • A sample costs voices × roughly STAGES/2 engine cycles. The ring keeps a voice in the pipeline for half its depth, so the depth the compiler chose is also the width of the bar above.
Depth is not throughput
  • Both the clock the engine needs and the Fmax it reaches rise with STAGES, so raising it buys timing slack and spends flip-flops. The sustainable rate stays above 57 kHz at every value that fits. Sample rate was never the constraint on this port. Area was.
48 fits, and costs too much
  • On the ECP5 STAGES=48 closes at 59.2 MHz — still short of the 60 the USB PHY demands — and takes 70% of the device's flip-flops on a die that is already 93.5% full.
One backend, one pinned seed, an archive not a bitstream
The first half of the build is shared to the character. The second half has no Vivado in it, and does not end at top.bit.
Shared, to the flag
  • core/synth.xir_converteroptcodegenfix_verilog.py: the same script and the same pinned XLS build, with one number different. It still runs in Docker on linux/amd64, because XLS ships no arm64 binary
Then Amaranth, not Verilog
  • top.py elaborates the shell around the engine, which is handed to the build by contents rather than by path — yowasp's yosys runs under WASI and cannot see a file outside its own working directory
One backend, fully open
  • yosys → nextpnr-ecp5 → ecppack, all through yowasp and all native on Apple Silicon — the reference core end to end in 35.8 s, where the Basys 3's Vivado path needed a remote VM. P&R is pinned: --router router2 --router2-tmg-ripup --seed 7 --timing-allow-fail, the last flag because this design has never closed 60 MHz
The output is not a bitstream
  • A .tar.gz whose manifest.json carries clk0_hz: 12288000, written to bootloader slot 7. The bootloader programs the SI5351 from that manifest on every cold boot — so the engine's clock lives in the flash image, not in the gateware
93.5% of the die, and a 6% bet
The Artix-7 build sits at about half the chip. The ECP5 one has no such room, and everything on this slide follows from that.
Full, by every measure
  • 22,722 of 24,288 logic cells at 24 voices — 93.5%, and 98.3% at 32 · 27 of 28 multipliers · 53 of 56 block RAMs
The clock does not close
  • The USB PHY fixes that domain at 60 MHz; nextpnr closes the 24-voice build at 56.63. Shipping it is a bet that the silicon is 6% faster than the model — at 32 voices the same bet is 29%, and it has already failed on one module
Placement is the variable
  • At this occupancy the router seed decides. A 24-seed sweep spanned 51.60 → 56.63 MHz on one netlist — and winners do not transfer: the seed that takes the 56.63 read 50.90, near the bottom of the draw, one commit earlier
Even the git hash moves it
  • The build stamp is a ROM, so a dirty tree's six extra characters change its width, change the netlist, and re-draw the whole lottery. A release build is a clean-tree build, and every netlist change costs a fresh sweep
A 720×720p60 display with no framebuffer
Because there was nowhere to put one — and the design got smaller for going without.
Race the beam

Each pixel's colour is computed in the cycle before it is sent, from the beam position and a 32-byte store: one 15-bit word per voice, brightness from its envelope and hue from its pitch.

A framebuffer holding the same information is 1.5 MB. The whole die has 56 block RAMs.

One BRAM is the whole crossing

That store is dual-port: written from the audio domain at 12.288 MHz, read from dvi at 39.07. No FIFO, no handshake, no synchroniser.

Neither side needs to know what the other is doing — the reader only ever wants the newest value, which is what the port hands it.

It paid for itself

The SDK's video path streams out of PSRAM, which is where the echo delay line lived. Going framebuffer-free removed the second client — and then removed PSRAM.

24,107 cells (99%) → 23,404 (96%), and that domain's Fmax went 40.17 → 44.71 MHz. The design gained a screen and shrank.

The tiles themselves are 235 cells — 1.0% of the device. Rendering them on the host and streaming pixels in was killed by measuring it: 93 MB/s wanted, against a USB device with no bulk endpoint and a ~40 MB/s ceiling.

The loop reached the second board by swapping one object
Same 175 cases, same grader, same thresholds — a Transport that read UART bytes became one that opens a UAC2 capture.
  • The shipped 24-voice build grades 99.8 / 100 (A+) on the module — 174 pass, 1 warn, 0 fail, USB frame gaps 0.00% across 175 captures
  • That one warn is a filter-sweep brightness measurement both boards share, and it predates the port
  • The boards disagree about sample rate, so the suite is told which one it is talking to and then asserts the rate it actually got

And the part that does not fit the story: an earlier and slower 32-voice bitstream ran perfectly here, and did not run on one of the maker's two modules. Static timing says neither should work. On this die it does.

Still open, still undiagnosed — and the reason 24 voices, not 32, is the build this repo stands behind.

Recorded off the module over USB — four parts, no room mic, no software instruments:
The panel playing the modulelive demo Bach · Goldberg Aria4 parts · 1:45 Vivaldi · Winter, Largo4 parts · 1:45
1 / 32
navigate   f fullscreen