← All storiesRead as Markdown ↗
Notes from building

Teaching a Traktor Kontrol S5 to Speak Mixxx

A week of forking Mixxx so a Native Instruments controller, screens and all, runs on open-source DJ software: USB quirks, a screen designed from scratch, and the colour bug my own tests couldn't see.

Article cover
Article cover

Article cover

The Traktor Kontrol S5 is a controller built for exactly one program. It has four decks of buttons, sixteen RGB pads, and two small colour screens that show waveforms, cue points, and the track browser. All of it assumes Traktor Pro is on the other end of the USB cable.

On October 23 I stopped using Traktor. One night later I played a gig on the same S5, driven by Mixxx, screens included. This is how that happened, and what the week after it taught me.

The first night

Why leave Traktor

The trigger was analysis. Traktor read 100 of my 107 UK dubstep tracks at around 70 BPM instead of 140. I had already built a pipeline that pulled beat grids and cue points from a music-structure model running on an extra GPU server I set up for the heavy compute, and Traktor kept being the weakest link in it: its library file has to be edited while the app is closed, and an import silently replaces a track's whole entry.

Mixxx is open source, keeps its library in SQLite, and its newer versions can render Qt Quick scenes onto controller screens. That last part is what made the S5 look possible rather than hopeless.

Mixxx's own analyser wasn't the answer either. It reads drum & bass at half tempo, and about a third of the tracks I checked landed half a beat off. So grids come from my own pipeline, and Mixxx is the place they end up.

Learning the hardware from scratch

Mixxx ships mappings for the S2, S3, S4 and several other Native Instruments controllers, but none for the S5. The best I found online was a hardware note giving the packet format: buttons arrive in a 30-byte report, faders and knobs in a 79-byte one. Its tables of which bit is which button didn't match my unit, so I had to learn the controller myself.

Buttons, knobs and faders

The first try was naive. I recorded 40 seconds of raw packets while pressing a list of buttons in order, and we tried to line the presses up afterwards. It didn't work. I pressed faster than the plan assumed, and a few bytes in the button report change on every packet anyway because they carry touch positions, so they looked like presses.

So I asked for a recording app instead. It became a small local web page with every control on a grid. I pick one, press record, and press and release that control once while a four-second bar runs. The result shows straight away: green with the byte and bit it found, red if it found nothing, orange if several bits moved and the answer is ambiguous. Any single control can be re-recorded without redoing the rest.

The detection rules are simple:

  • a button is one bit that goes up and comes back down
  • an endless encoder is a 4-bit counter that steps several times
  • a fader or knob is a 12-bit field that moves across a real part of its range

Bytes that change constantly are measured first and excluded.

The most useful decision was to log every raw packet to a file, with start and end markers for each recording. Early on, every press came back red. The bug was ours: each window held the press and the release but not the state just before them, so the rule never saw a rise. With the log, we fixed the rule and recomputed every earlier result without me pressing anything again.

The log caught smaller things too. The left-hand encoders count the opposite way from the right-hand ones. The LOOP knob's touch sensor fires along with its push. After a couple of hours I had 112 controls mapped: transport, pads, mode buttons, encoders, touchstrips, every fader and knob on the mixer, and the eight buttons around each screen.

Lights

LEDs are harder because nothing comes back. You write a byte and look. The S5 has three output reports: 118 bytes for each deck and 73 for the mixer, one brightness byte per light, three per RGB pad.

I started by checking the published LED table with a few test buttons on the same page: light everything, light one group, light the left pads red, light the mixer's CUE keys. The decks matched. The mixer didn't: "CUE keys" lit the FX assign buttons instead. Clicking through 73 mixer positions one at a time and writing down what lit up would have been slow and boring, so we automated it.

The page got an automatic sweep. It flashes every light in a group once, then lights one position at a time for half a second each, across all three groups in about three minutes. I put my phone on a stand, framed the whole controller, started recording, and started the sweep.

A script then reads the video:

  1. It decodes the video small and in greyscale and finds the all-on flashes as brightness peaks. Those flashes line the video up with the sweep's timeline.
  2. For each position, it compares the middle of its half-second with the dark frames before the group. The brightest spot of the difference is where that LED sits.
  3. It writes an annotated photo of the controller for each group, with each position's number drawn on the light it lit.

The sweep, read back from the video

It found 64 of the 73 mixer positions and 109 and 104 of the 118 on the two decks. Most of the rest are bytes with no LED behind them. The published table really was wrong in places: PLAY is byte 53, not 59, and the four channel CUE keys aren't in channel order. A couple of lights hidden by glare were filled in from the other deck's layout, and the sweep could be re-run for one group at a time to check them.

With the positions known, I could choose how the lights behave. Nothing goes fully dark: "off" is a dim backlight, so the labels stay readable in a dark room, and "on" is full brightness. Pads show each cue's colour.

The mapping itself is a JavaScript file Mixxx loads, about 1,200 lines now: decks A to D, hotcue pages, loops and beat jumps sized by the loop encoder, FLUX that returns to where the track would have been, and a browser that sorts by key or BPM from the ZOOM buttons.

The screens needed a fork

The screens are a separate USB bulk interface. Mixxx already supports bulk screens for the Kontrol S4 MK3, but it only opens devices from a list compiled into the binary, and the S5 isn't on it. Adding one line got the device recognised. Making it work on a Mac took a lot more.

  • The sound card disappeared. On macOS, libusb's auto-detach captured the whole device, taking the S5's audio interface with it. The fork skips that call on macOS.
  • Re-enabling crashed. After a failed claim, Mixxx kept a stale handle. The fork clears it and retries a busy interface briefly before giving up.
  • Native Instruments' background agent holds the screens. NIHardwareAgent claims the interface and relaunches itself when it loses it. My launcher keeps it off.
  • Metal crashed. Mixxx renders controller screens through OpenGL but left Qt Quick on Metal, which crashed mid-frame. The launcher forces the OpenGL backend.
  • Startup crashed sometimes. The GL context wasn't current before Qt Quick synced the scene, and the track-list model was being created on a screen thread while the library view painted it. Both are fixed in the fork.
  • The audio stayed dead after a session. After anything had driven the screens, even after a clean quit, the S5's sound card was gone for every program until the device re-enumerated. The launcher now resets the S5 over USB before Mixxx starts.

Two additions were features rather than fixes. Controller scripts can now share data (engine.setSharedData), which the S4 MK3 script already expected and Mixxx didn't have. And the library exposes what its window is showing, so the screen can mirror the browser.

Before the gig I also added hotplug: if the S5 loses power or its cable, Mixxx reopens the controller about two seconds after it comes back, and a watchdog reopens the sound card if macOS doesn't. I tested it by pulling the power on the real unit. The build that played the gig is tagged, with a one-command fallback to it.

The fork is public at github.com/yuwei-liang/mixxx on the s5 branch: 22 commits on top of Mixxx's main, merged forward when upstream moves.

Designing a screen instead of copying one

With pixels on the panel, the question became what to put there. I looked at what Native Instruments, Pioneer and Denon show, and what DJs complain about in forums. Two complaints stood out: columns you can't change, and a screen that lags behind the software, which is worse than no screen at all.

What I ended up with, 480 × 272 per deck:

  • A header with deck, title, artist, remaining time, BPM, key, and KEYLOCK / SYNC / QUANTIZE / loop state.
  • A countdown to the next cue in bars and beats, plus a 16-bar phrase counter. I count phrases by ear, but a counter is a good check.
  • An overview with numbered cues coloured by role. The drop is red; the purple marks show the outro and the points 16 and 8 bars before it, where I start a long blend.
  • A faint "ghost" of the waveform at full EQ when I've cut the bass, so I can see what's coming back.
  • A pixel-art cat riding the playhead, because nobody stopped me.

Pop-ups on the hardware

Both S5 screens, rendered by the fork

Getting it smooth meant not doing work twice. The screen code compares each frame with the last one, four bytes at a time, and skips the colour transform entirely when nothing changed.

My buttons were just an interface

For the first day I was the test harness. Code compiled, I loaded it, the AI agent I was working with asked me to press a button, I reported what happened. One missing function went unnoticed through several rounds that way.

So we built a way to drive Mixxx without the controller. Two test hooks, off unless an environment variable is set, read commands from a file (set a control, load a track, write shared data) and dump every screen frame to disk. The mapping is tested separately in Node by feeding it raw S5 reports and checking which Mixxx controls change. After that, only things that genuinely need eyes, ears or hands came back to me.

That setup had a blind spot I didn't notice for six days. The screens had shown red and blue swapped from the very first frame. The drop marker I'd designed as red was blue on the panel, and the blue deck badges were yellow. The test tool decoded the dumped frames using the same wrong assumption about the pixel format, so its PNGs looked perfect.

A photo of the panel from my phone caught it in seconds. The pads had a related problem: every cue colour looked washed toward white, because the LEDs glow visibly at low values. Running pad colours through a gamma curve fixed it, and that was also judged against photos rather than numbers.

The lesson I took from it: a check that shares the code's assumption can't catch that assumption. For anything visual on this controller, the reference is now a photo of the hardware.

Where it's going

The S5 is now the front end for the rest of my DJ tooling. Grids and cues land in Mixxx's database from the analysis pipeline, which can also be triggered from Mixxx's track menu, with progress shown on the screen. Pressing REMIX asks a small local service what to play next: it plans each candidate's handover from the cue structure, and the screen shows the options on pads 1 to 5.

None of that is upstreamed, and right now I'm fine with that. The point was never a polished mapping for every S5 owner. I wanted the controller I already owned to stop depending on software I'd stopped trusting, and I wanted every step to be something I could open up and change.

DJ Fred

Written from real project iterations. More notes as the work continues.