A deck is a small network.
A CDJ's main board does everything. TriMixxx splits the work: tiny RISC-V nodes for the pads, an ESP32-S3 for every control, a Raspberry Pi for the music and a Rust library for the network. They talk over plain UARTs and plain MIDI, and any piece can be swapped without touching the rest.
Top to bottom.
Each layer below has its own section, in the order a press of PLAY travels through them: from the copper up.
Network
Joins a Pro DJ Link network beside CDJs: browses their sticks, serves its own, follows and takes tempo master.
DJ software
A Mixxx 2.5.6 fork with a CDJ-style skin and browser for the deck's touchscreen, rekordbox waveforms and grids, SYNC, KEY SYNC, History and Record set.
Operating system
Raspberry Pi OS on a Pi 4: the UART becomes a MIDI port, a launch manager picks Mixxx, Doom or a rescue console, and A/B slots take signed updates that roll back on their own.
The link
Raw MIDI over a 3.3 V UART at 115 200 baud. Notes and CCs go up; LED states and RGB SysEx come back down.
Controller
An ESP32-S3 reads every control: the jog through the hardware pulse counter, the tempo fader at 14 bits, both button rings on their own UARTs.
Satellite boards
Thirteen pads on two rings of CH32V003 nodes, each with a button and two RGB LEDs, passing one frame round the ring at 500 kbaud. Play, cue and loop on boards of their own.
Controls
An optical jog wheel with touch, a tempo fader, a push encoder, play, cue, loop in, out and reloop: laid out like the deck a DJ already knows.
Real parts, read properly.
The jog wheel is a CDJ-1000MK3 jog assembly. A Sharp GP1A038RBK optical sensor reads its 3,240-line disk in quadrature: 12,960 counts a turn, counted by the ESP32-S3's hardware pulse counter, so no interrupt ever fires and no count is lost. The signal is clean from the comparator, so it is not debounced. A touch switch in the platter decides between scratch and bend.
The tempo fader is read ratiometrically: the wiper minus a live reading of the centre tap, so the centre lands on the middle without calibration. Each side has its own span because the centre tap isn't centred, and the value is smoothed, given hysteresis and sent as 14-bit MIDI. A single 7-bit CC would have cut the whole pitch range into 128 steps.
The browse encoder is mechanical, so it is the opposite case: a state-table decoder rejects contact bounce and gives exactly one tick per detent. The buttons have 100 nF on the board and are polled, never put on an edge interrupt, which a slow RC edge can make fire twice.
Thirteen pads, one frame.
The RGB pads sit on OneButton nodes: 20 mm boards with a CH32V003 RISC-V microcontroller, a switch and two RGB LEDs. They are daisy-chained into two rings, seven nodes and six, each ring on its own UART of the S3 at 500 kbaud.
The S3 sends one fixed-length frame round the ring. It carries every LED's colour outward, and every node writes its button into its own slot as the frame passes through. Nodes relay cut-through: each byte goes out as soon as it arrives, so a frame's trip costs one frame time plus a byte per node, not a frame per node. Each node re-clocks the bytes, so UART clock error never adds up along the chain.
Addresses are positional. At start-up the S3 sends an enumeration frame with a hop count of zero; each node takes the count as its index and passes on count + 1. Every node runs the same binary with no configuration, and a ring sizes itself to however many nodes are fitted, up to 50. Ten bad frames or a second of silence, and the S3 enumerates again.
The protocol is written up as a specification, with its timing and failure model: the OneButton Protocol. The node firmware is bare-metal C on ch32fun, and its WS2812 timing was tuned for margin and held at 150 °C.
An ESP32-S3 with house rules.
The S3's firmware is PlatformIO and Arduino on FreeRTOS. Each peripheral is a self-contained module: the two rings, the jog, the fader, the encoder, the play/cue and loop boards, and the link to the Pi. Only main.cpp knows how they connect, and one switch swaps the MIDI loop for each module's own self-test.
- No heap. Every buffer is sized at compile time; memory use is known at link time.
- Hardware before software. The pulse counter for the jog, UARTs for the rings.
- Two cores, no contention. The ring task owns core 0; the button poller runs on core 1. Moving the fader's ADC reads off the main loop took it from about 480 Hz to about 98 kHz, and the jog is reported every 300 µs.
- Formatted and checked. clang-format and cppcheck run on every commit.
Plain MIDI, one header file.
The S3 and the Pi share a 3.3 V UART at 115 200 baud, so no level shifting is needed, and on it travels raw MIDI on channel 1. To Mixxx, the deck is an ordinary MIDI controller. Every address lives in one place, MidiMap.hpp. The Mixxx mapping matches it, and the emulated deck's virtual S3 compiles the same header in, so the contract can't drift.
| Control | MIDI | Mixxx |
|---|---|---|
| Ring A pad i | Note 0x00 + i | tempo range, master tempo, 8- and 4-beat loops, ×2, ÷2, back |
| Ring B pad i | Note 0x43 + i | hot cues 1–4, slip, SORT / KEY SYNC |
| PLAY, CUE | Notes 0x3C, 0x3D | play (toggle), cue_default |
| LOOP IN, OUT, RELOOP | Notes 0x3E–0x40 | loop_in, loop_out, reloop_toggle |
| Track encoder | CC 0x10 relative + Note 0x41 | browse, open, load |
| Jog wheel | CC 0x11 relative + Note 0x42 (touch) | scratch when touched, bend otherwise |
| Tempo fader | CC 0x12 MSB + CC 0x32 LSB, 14-bit | rate, picked up by the script |
| SysEx | Way | Does |
|---|---|---|
F0 7D 01 node RGB… F7 | Mixxx → S3 | Ring A colours, each 8-bit channel split into two nibbles |
F0 7D 03 node RGB… F7 | Mixxx → S3 | Ring B colours, the same way |
F0 7D 02 'RST' F7 | Mixxx → S3 | Reboot the S3; the magic word stops a stray message |
F0 7D 04 F7 | Mixxx → S3 | Report the tempo fader's position now |
F0 7D 01 F7 | Mixxx → launchd | Power off (and 02 reboot) |
F0 7D 03 'SLOT' F7 | Mixxx → launchd | Restart into the other A/B slot |
F0 7D 20–22 'MIX'… | Mixxx → launchd | Switch mode: Mixxx, DOOM or the rescue console |
A 7-bit Note velocity can only set a pad's white brightness, so colours travel as SysEx under 0x7D, MIDI's ID for non-commercial use. Each 8-bit channel is split into two nibbles to fit SysEx's 7-bit bytes.
From copper to the network.
- 0 ms
Press
The switch shorts a 100 nF capacitor; the S3's pin, held high by its pull-up, falls. Buttons are active-low.
- ≈ 6 ms
Debounce
A FreeRTOS task on core 1 samples the pin every 2 ms and wants three stable samples. It latches the press, so a stalled main loop can't miss it.
- + 0.26 ms on the wire
MIDI out
The main loop takes the latch and sends Note-On 0x3C, velocity 127, three bytes on UART0.
ttymidi
On the Pi, ttymidi turns the bytes into an ALSA sequencer event on the TriMixxx port.
Mixxx
The mapping matches note 0x3C and calls the script's play handler, which toggles [Channel1] play.
The light
Mixxx sends play_indicator back on the same note, and the S3 lights PLAY from it: the lamp shows Mixxx's state, not the button's.
The network
The deck's next status packet tells every player on the link that it is playing: at once on a change, otherwise every 200 ms.
A Pi that boots into a deck.
A Raspberry Pi 4 runs Raspberry Pi OS Lite, with every unit, rule and script that makes it a deck in pi_config/. Sound goes out through a Behringer UCA222 at 44.1 kHz with a 256-frame buffer, 5.8 ms. A 512-frame buffer measured cleaner under heavy scratching, but 256 was kept on purpose: the jog's lag is a multiple of the buffer, and the smaller one keeps it feeling attached. The CPU runs the performance governor: ondemand samples load only every 100 ms and waits for half of all four cores to be busy, which can leave a single saturated audio core at the lowest clock.
Bootloader
The Pi's EEPROM reads which slot to boot, A or B. An update's first start is a one-shot trial.
- panel ≈ 1.7 s
Kernel and initramfs
The panel's drivers sit in the initramfs, so the screen is up early instead of after the root mounts.
- ≈ 8 s after power-on
Splash
The logo goes up on a spare console while the boot log keeps printing behind it: a failing unit is still visible before a gig.
Identity
The deck's name, Wi-Fi networks and ssh keys come from their own partition into RAM; the system itself carries none.
The MIDI bridge
ttymidi opens the UART and creates the TriMixxx MIDI port before anything that would look for it.
Launch manager
trimixxx-launchd watches the deck's buttons for three seconds and picks the mode: Mixxx, DOOM or the rescue console. Whatever fails, the answer is Mixxx.
- ≈ 16 s
Mixxx
Autologin starts X and Mixxx, full screen. If Mixxx ever exits, it is back two seconds later.
Health check
On a trial start only: if the network and ssh are up within 150 s the release is kept, otherwise the deck reboots into the one before.
MIDI is the only way out of Mixxx
Mixxx's controller scripts run in a JavaScript engine with no file, process or network access. So the system-level jobs, shutting down, switching mode, restarting into the other slot, are SysEx messages to a second MIDI device: trimixxx-launchd, a static Go binary. Each message picks an action from a fixed table, with magic words where a stray byte could hurt, and no MIDI byte ever reaches a shell. Its sibling, trimixxx-deckkeys, replays the deck's MIDI as a virtual keyboard and mouse, which is how DOOM and the rescue console are played.
USB sticks, read-only
A udev rule mounts every stick read-only, and that is the whole safety model: nothing on the deck can write to a DJ's stick except Record set's one copy command, which remounts it read-write for the copy alone.
Two of everything that changes.
A release card has eight partitions. The boot files and the system come twice, A and B; the deck's identity and its state come once and outlive every update. An update is a RAUC bundle written into the idle slot, started with the Pi's own one-shot tryboot, and kept only if the health check passes. The system itself is a read-only SquashFS under a layer in RAM.
- p1which slot boots64 MiB
- p2boot A512 MiB
- p3boot B512 MiB
- p5system A, SquashFS4 GiB
- p6system B, SquashFS4 GiB
- p7the deck's identity64 MiB
- p8History, recordings, RAUC4 GiB
Every release is rehearsed on emulated decks first, fault table included: a damaged bundle, a power cut during the copy, no network, a kernel panic, a hung initramfs. The table was then run on a bench Pi, with a real power cut five seconds into an install. Bundles are integrity-checked; the signing key is in the repository on purpose, because anyone may build and sign a release for their own deck.
Mixxx, forked for a deck.
The deck runs a fork of Mixxx 2.5.6, the open-source DJ software. The fork keeps its changes to upstream small, 45 of Mixxx's own files touched, and puts the deck's own code in layers of its own: the deck widgets, the deck library, the rekordbox reader and the Pro DJ Link glue, over the Rust library. One owner starts and stops all of it.
- The skin is XML and QSS, the mapping XML and JavaScript: both change in seconds, with no compiler.
- Each deck has a small JSON file: its accent colour, its own button wiring, whether its jog runs backwards.
- Settings are the repository's. The fork never writes its config back, so the deck's settings are exactly the file in git.
- Built for arm64 in Docker: a cold build in about ten minutes, a one-file change on a deck in about 25 seconds.
Pro DJ Link, from the wire up.
CDJs talk to each other over plain Ethernet, with self-assigned 169.254 addresses. TriMixxx learned their protocol by watching two CDJ-2000NXS players it owns, and implements it in prolink, a Rust library the fork links in.
| Port | How | What travels |
|---|---|---|
| UDP 50000 | broadcast | Who's here: hello, claim a player number, then a keep-alive every 2 s. Five missed means gone. |
| UDP 50001 | broadcast | Beats: tempo, pitch and the position in the bar. Master hand-over requests travel here too. |
| UDP 50002 | unicast | Status every 200 ms, sent only to players that have announced themselves; also the media queries. |
| UDP 111 → NFSv2 | unicast | Files: portmapper, mount, then the audio itself, streamed as 8 KB reads. |
| TCP 12523 → 1051 | unicast | dbserver: menus, metadata, artwork, waveforms, beat grids and cues. |
The library is six crates: the wire format with no I/O, the rekordbox database, captures, the session, a command line, and the bridge to C++. Its 805 tests include a replay of 37 sessions captured from real players: 9,308 discovery packets re-encoded byte for byte, 46,103 status packets, 65,705 dbserver messages and 60,138 RPC calls, with no failure. Its types encode the protocol's traps, such as a player number that can only be 1 to 4, because at 5 a CDJ accepts you and then never asks you anything.
Everything it knows comes from captures of the author's own hardware; no code from other implementations was copied. The protocol as observed is written up, with the evidence for every claim, in prolink's PROTOCOL.md.
Decks that run on a laptop.
pi-qemu runs a whole deck on an Apple-silicon Mac: QEMU's Raspberry Pi 4 with 21 patches of its own, booting the deck's real card unmodified. A virtual S3 compiles the real MIDI contract and plays the panel; a folder becomes a USB stick without being copied; several decks share a network and see each other over Pro DJ Link. From a snapshot, a deck is up with Mixxx running in about three seconds.
Beside them, an emulated CDJ-2000NXS boots Pioneer's own firmware, which its owner supplies and the repository never contains, through a fork of cdj2000-emulator. A change to how the deck speaks Pro DJ Link can be tried against a CDJ without a booth.
