Reverse Engineering Pioneer Pro DJ Link with Claude Code
Pioneer’s Pro DJ Link is the protocol that lets CDJs and DJM mixers on a
network find each other, agree on player numbers, and share beat, tempo, and
track metadata in real time. It is entirely undocumented by Pioneer. Everything
the open-source world knows about it comes from people watching the wire — most
notably the Deep Symmetry project and
its beat-link and crate-digger libraries.
We want a clean Go port of that work for deadca7, and we wanted to build it reliably. This post is about how we did the reverse engineering itself: a human with real hardware in the room, and Claude Code driving captures, decoding packets, and writing the tests — together, in one chat.
Motivation
We had a head start: an earlier port lived in vizlab/pkg/link. It worked
sometimes and fell over often. The instability wasn’t mysterious once we looked:
it announced itself to the network by blasting only keep-alive packets, never
performing the startup handshake real players expect. Sometimes the CDJ accepted
it; usually it didn’t.
Rather than port the bug, we started over with a capture-first methodology:
- Document each packet we expect to see, and what we think it means.
- Capture a real fixture from the hardware — a CDJ-3000 and a DJM-V10 mixer.
- Decode that fixture under test, so no test ever needs live hardware again.
The rule that fell out of this: cmd/capture is the only thing that touches the
network. It records datagrams into testdata/*.jsonl. Every decoder test replays
a fixture. The reverse engineering is live and messy; the test suite is hermetic
and fast.
Collaborative Reverse Engineering
The interesting part of this work was the loop between a human standing at the gear and a model that can write a capture tool, read 314 packets, and propose a decoder in seconds. The entire session — every prompt, every tool call, every captured packet — is published verbatim: read the full session transcript. Some real exchanges from it:
Human: help plan, whatever is in
link/is in the wrong place. if used, move that to app.linkis going to be a port of Pioneer Pro Link integration to Go… we’ve started that port in vizlab but it’s not reliable so I think we want to build some tests first by documenting how, then performing packet captures from our CDJ-3000.
We started by getting the room ready, which is something only the human can do:
Human: it’s on and a reference SD card is loaded. can you verify
Claude: keep-alive (
0x06) captured from two devices — CDJ-3000 (device 3) and DJM-V10 (device 33). Name sits at offset0x0c, device number at0x24, IP at0x2c. Saved totestdata/devices.jsonl.
From there the human became the protocol’s stimulus — pressing play, nudging the jog wheel, powering a second CDJ on and off — while Claude captured and decoded the result:
Human: let’s do more passive packets… let me know and I can start playing a track.
Claude: Capturing 60 seconds →
testdata/playing.jsonl. Go ahead and play. If you can, load a track, press play, nudge the jog wheel, move the tempo fader… anything you do will show up.
Human: the mixer is on now too — capture packets from it?
Human: yes do the device-number negotiation thing while we’re here.
Human: I have another CDJ we can force the conflict… I believe 3 is up, and I could turn 2 or 4 on when we’re ready to capture.
Human: looks right — on the CDJ it looks like it gets then loses 4 over and over again.
That last exchange is the whole methodology in one line. We held device number 4
with our virtual CDJ, the human powered on a real CDJ hard-coded to 4, and we
watched it oscillate — getting then losing the number — as our code defended it
28 times. The conflict packets landed in testdata/claim.jsonl, and the defense
logic was tested against them. No guessing; we made the protocol misbehave on
purpose and recorded what happened.
Harness
The session ran inside Claude Code (in Conductor), which mattered for two reasons:
- Background captures. A capture is a 60-second blocking command. Running it as a background task meant the human could play a track and report what they did while the capture ran, then we’d analyze the result together when it completed.
- Fixtures as the source of truth. Because every capture was saved as JSONL, a decode that looked right could immediately become a test. The conversation produced artifacts, not just answers.
The capture tool is deliberately small — passive recording plus an optional virtual CDJ that announces itself so real players will send it status:
link/
├── cmd/capture/main.go # passive UDP recorder → JSONL fixtures
├── packet.go # Magic, Type, DecodeKeepAlive, DecodeBeat
├── announce.go # virtual CDJ: startup handshake + number defense
├── net.go # interface / broadcast helpers
├── packet_test.go # fixture-replay decoder tests
└── testdata/*.jsonl # real captures from the CDJ-3000 and DJM-V10
Two reliability fixes from the old port were verified live and locked in by this
harness: a proper four-stage startup handshake (hello → claim → keep-alive)
instead of blasting keep-alives, and SO_BROADCAST on the socket so
subnet-broadcast sends don’t silently fail.
Protocol Flows
Pro DJ Link is a set of UDP broadcasts split across three ports. Every packet opens with the same ten-byte magic preamble and a type byte.
Discovery — port 50000
Every device announces itself a few times a second with a keep-alive. That’s how the network builds its picture of who is present and which numbers are taken.
Virtual CDJ startup handshake — port 50000
To receive status and metadata, you can’t just lurk — you have to come online as a player. Real players run a fixed four-stage handshake. Reproducing it exactly is what made our client stable.
Device-number negotiation and defense — port 50000
Numbers are claimed cooperatively. A device can self-assign after watching the network, accept an assignment from a mixer, back off if its claim is rejected, and defend a number it already owns when someone else tries to take it.
Beat sync — port 50001
A playing deck broadcasts a beat packet on every beat. This carries the live tempo, the position within the bar (1–4), and the pitch fader value.
Packet Diagrams
Every packet shares a common header: the magic preamble, then a type byte at
offset 0x0a that — read together with the UDP port — tells you what you’re
holding.
Keep-alive (type 0x06, port 50000)
The announcement that builds the network’s device map. Decoded by
DecodeKeepAlive:
Beat (type 0x28, port 50001)
Broadcast on every beat by a playing deck. Decoded by DecodeBeat:
The decoder is a direct read of these offsets — small enough to quote in full:
func DecodeBeat(b []byte) (Beat, error) {
if len(b) < 0x60 || !bytes.Equal(b[:len(Magic)], Magic) {
return Beat{}, ErrNotDJLink
}
if t := Type(b[0x0a]); t != TypeBeat {
return Beat{}, errors.Errorf("want beat 0x28, got 0x%02x", byte(t))
}
return Beat{
BeatInBar: b[0x5c],
BPM: float64(binary.BigEndian.Uint16(b[0x5a:0x5c])) / 100,
Device: b[0x5f],
Name: string(bytes.TrimRight(b[0x0b:0x1f], "\x00")),
Pitch: int(b[0x55])<<16 | int(b[0x56])<<8 | int(b[0x57]),
}, nil
}
What’s next
The discovery, handshake, number-defense, and beat flows are decoded and under
test. Still on the list: decoding the full 0x0a status packet (play state,
on-air, master, loaded track), the DJM-V10 mixer streams, and the crate-digger
path — reaching the rekordbox database over NFS to resolve track metadata.
Capture-first, one fixture at a time.