Reverse Engineering Pioneer Pro DJ Link with Claude Code

Reverse Engineering Pioneer Pro DJ Link with Claude Code

By Noah Zoschke 8 min read

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:

  1. Document each packet we expect to see, and what we think it means.
  2. Capture a real fixture from the hardware — a CDJ-3000 and a DJM-V10 mixer.
  3. 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. link is 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 offset 0x0c, device number at 0x24,
IP at 0x2c. Saved to testdata/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.

CDJ-3000 (dev 3)broadcast 255.255.255.255:50000DJM-V10 (dev 33) keep-alive 0x06 (name, devkeep-alive 0x06 (name, devevery device learns {3, 33}

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.

Virtual CDJOther players hello 0x0a ×3 @300msclaim-1 0x00 ×3claim-2 0x02 ×3 (IP + MAC)claim-3 0x04 ×3 (number)keep-alive 0x06 @1500ms — online0x0a status stream

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.

Virtual CDJRival CDJ (wants 4) watch keep-alives 2s → used {3, 33}self-assign lowest free ≥ 5claim 4defense 0x08 — 4 is mineoscillates 4 ⇄ 25 (defended 28×)

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.

CDJ-3000 (playing)Listeners beat 0x28 (BPM, beat-in-bar 1..4, pitch)effective tempo = BPM × pitch / 0x100000

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.

Common header0x00–0x09 Magic51 73 70 74 31 57 6d 4a 4f 4c (Qspt1WmJOL)0x0a Type0x06 keep-alive · 0x28 beat · 0x0a hello · 0x08 defense

Keep-alive (type 0x06, port 50000)

The announcement that builds the network’s device map. Decoded by
DecodeKeepAlive:

Keep-alive 0x060x0a Type0x060x0c Name20 bytes, NUL-trimmed (CDJ-3000 / DJM-V10)0x24 Device1 byte (3 = CDJ, 33 = mixer)0x26 MAC6 bytes0x2c IP4 bytes (192.168.0.192)

Beat (type 0x28, port 50001)

Broadcast on every beat by a playing deck. Decoded by DecodeBeat:

Beat 0x280x0a Type0x280x0b Name20 bytes, NUL-trimmed0x55 Pitch3 bytes BE — 0x100000 = 100%0x5a BPMuint16 BE centi-BPM — 0x3420 → 133.440x5c BeatInBar1..4 within the bar0x5f Devicewhich player sent it

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.