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.