Architecture

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.

TriMixxx's system, from the controls to the networkSeven controls and boards on the left connect to the midi_s3_mini controller over UARTs, GPIOs, the pulse counter and the ADC. The controller talks MIDI to the Raspberry Pi over a UART at 115 200 baud. The Pi runs ttymidi, the Mixxx fork, the prolink library, the launch manager, the USB automount and RAUC, and connects to the touchscreen, USB sticks, the UCA222 sound card, the Pro DJ Link network and Wi-Fi.CONTROLS AND BOARDSCONTROLLERCOMPUTERAROUND ITUART1 · 500kUART2 · 500kGPIOGPIOPCNTADC1GPIOraw MIDI115 200 baudRing A7 OneButton nodes, RGB padsRing B6 OneButton nodes, RGB padsplay_cue_btnPLAY, CUE, 8 LEDsloop_btnLOOP IN, OUT, RELOOPJog wheeloptical quadrature + touchTempo fadercentre-tapped slide potTrack encoderKY-040 with pushTouchscreenDSI or HDMI panelUSB sticksrekordbox or plainUCA222RCA out and inPro DJ Linketh0: CDJs, other decksWi-Fissh, updates, own hotspotmidi_s3_miniESP32-S3 · FreeRTOSPiLink · MIDI in and outOneButtonRing × 2 · core 0Buttons · 2 ms poll, latchedJogWheel · pulse counterTempoFader · 14-bitTrackEncoder · state tableMidiMap.hpp · the contractRaspberry Pi 4Raspberry Pi OS · systemdttymidi · UART to ALSA MIDIMixxx fork · skin, browser, scriptprolink · Pro DJ Link in Rusttrimixxx-launchd · modes, SysExdj-usb · read-only automountRAUC · A/B slots, health check
The layers

Top to bottom.

Each layer below has its own section, in the order a press of PLAY travels through them: from the copper up.

  1. Network

    Joins a Pro DJ Link network beside CDJs: browses their sticks, serves its own, follows and takes tempo master.

    • prolink (Rust)
    • NFSv2 + dbserver
    • beat sync
    usr-ein/prolink
  2. 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.

    • Mixxx fork (C++/Qt)
    • skin XML + QSS
    • controller script (JS)
    usr-ein/mixxx
  3. 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.

    • ttymidi
    • Go daemons
    • systemd + RAUC
    pi_config/ · trimixxx-launcher/
  4. 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.

    • MidiMap.hpp
    • SysEx 0x7D
    firmwares/trimixxx-midi/lib/PiLink
  5. 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.

    • ESP32-S3
    • FreeRTOS
    • PlatformIO
    boards/midi_s3_mini · firmwares/trimixxx-midi
  6. 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.

    • CH32V003 (RISC-V)
    • WS2812
    • KiCad
    boards/ · firmwares/onebutton
  7. 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.

    • GP1A038RBK optical encoder
    • Fusion 360 chassis
    boards/ · ref_sizes/
01 · Controls

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.

02 · Satellite boards

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.

One OneButton DATA frame and the ring it travelsA frame starts with the type byte A5 and a sequence number, then one seven-byte slot per node: the red, green and blue of two LEDs, written by the controller, and a button byte that only the slot's own node overwrites. A CRC-8 ends it. The controller sends it to node 0, each node passes it to the next byte by byte, and the last node returns it to the controller.ONE DATA FRAME, 2 + 7·N + 1 BYTESA5SEQRGBRGBBTNRGBRGBBTN…RGBRGBBTNCRCheaderslot 0: node 0's LEDs, then its buttonslot 1slot N−1CRC-8ESP32-S3masternode 0CH32V003node 1CH32V003node 2CH32V003node 3CH32V003…node N−1CH32V003back to the master, with every node's button in its slot · 500 kbaud TTL UART

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.

03 · Controller

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.
A press of PLAY

From copper to the network.

  1. Press

    The switch shorts a 100 nF capacitor; the S3's pin, held high by its pull-up, falls. Buttons are active-low.

    0 ms
  2. 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.

    ≈ 6 ms
  3. MIDI out

    The main loop takes the latch and sends Note-On 0x3C, velocity 127, three bytes on UART0.

    + 0.26 ms on the wire
  4. ttymidi

    On the Pi, ttymidi turns the bytes into an ALSA sequencer event on the TriMixxx port.

  5. Mixxx

    The mapping matches note 0x3C and calls the script's play handler, which toggles [Channel1] play.

  6. 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.

  7. 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.

05 · Operating system

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.

  1. Bootloader

    The Pi's EEPROM reads which slot to boot, A or B. An update's first start is a one-shot trial.

  2. Kernel and initramfs

    The panel's drivers sit in the initramfs, so the screen is up early instead of after the root mounts.

    panel ≈ 1.7 s
  3. 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.

    ≈ 8 s after power-on
  4. Identity

    The deck's name, Wi-Fi networks and ssh keys come from their own partition into RAM; the system itself carries none.

  5. The MIDI bridge

    ttymidi opens the UART and creates the TriMixxx MIDI port before anything that would look for it.

  6. 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.

  7. Mixxx

    Autologin starts X and Mixxx, full screen. If Mixxx ever exits, it is back two seconds later.

    ≈ 16 s
  8. 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.

05 · Updates

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.

  1. p1which slot boots64 MiB
  2. p2boot A512 MiB
  3. p3boot B512 MiB
  4. p5system A, SquashFS4 GiB
  5. p6system B, SquashFS4 GiB
  6. p7the deck's identity64 MiB
  7. p8History, recordings, RAUC4 GiB
running slot idle slot, where the next update goes kept across updates

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.

06 · DJ software

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.
07 · Network

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.

Pro DJ Link's ports
PortHowWhat travels
UDP 50000broadcastWho's here: hello, claim a player number, then a keep-alive every 2 s. Five missed means gone.
UDP 50001broadcastBeats: tempo, pitch and the position in the bar. Master hand-over requests travel here too.
UDP 50002unicastStatus every 200 ms, sent only to players that have announced themselves; also the media queries.
UDP 111 → NFSv2unicastFiles: portmapper, mount, then the audio itself, streamed as 8 KB reads.
TCP 12523 → 1051unicastdbserver: 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.

Tooling

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.

Two columns comparing a real deck and an emulated deck layer by layer: the same card layout, the same boot rules, the same files, a different CPU and RAM, and the same software on top.
A real deck and an emulated one, layer by layer: the same card and the same software; only the board differs.

A board, in 3D

Drag to turn · scroll to zoom · right-drag to move · arrow keys and + − tooDrag to turn · pinch to zoom · two fingers to move