Write

// Package nfs is a minimal NFSv2 client for reading files off a Pioneer CDJ.
// The CDJ serves both the mount and NFS RPC programs on UDP 2049, so no
// portmap step is needed; paths and lookup names are UTF-16LE.
package nfs

import (
	"bytes"
	"encoding/binary"
	"io/fs"
	"net"
	"time"
	"unicode/utf16"

	"github.com/cockroachdb/errors"
)

const (
	authNull = 0

	mountProc = 100005
	mountVers = 1
	mountMNT  = 1

	nfsProc    = 100003
	nfsVers    = 2
	nfsGETATTR = 1
	nfsLOOKUP  = 4
	nfsREAD    = 6

	rpcCall = 0

	typeReg = 1

	chunkSize = 8092
	retries   = 5
)

// ErrNoMount is returned when the CDJ does not answer a mount request.
var ErrNoMount = errors.New("no mount response")

// Attr holds the NFS file attributes we parse; only Size and Type are used.
type Attr struct {
	Size uint32
	Type uint32
}

// FileHandle is an opaque NFSv2 32-byte handle.
type FileHandle [32]byte

// Client is a connected NFS client for one CDJ.
type Client struct {
	addr *net.UDPAddr
	conn *net.UDPConn
	xid  uint32
}

type header struct {
	XID      uint32
	MsgType  uint32
	RPCVers  uint32
	Program  uint32
	Version  uint32
	Proc     uint32
	AuthType uint32
	AuthLen  uint32
	VerfType uint32
	VerfLen  uint32
}

// New dials the CDJ's NFS service, e.g. "192.168.0.192:2049".
func New(address string) (*Client, error) {
	addr, err := net.ResolveUDPAddr("udp", address)
	if err != nil {
		return nil, errors.WithStack(err)
	}

	conn, err := net.DialUDP("udp", nil, addr)
	if err != nil {
		return nil, errors.WithStack(err)
	}

	return &Client{addr: addr, conn: conn, xid: uint32(time.Now().Unix())}, nil
}

func (c *Client) Close() error {
	return errors.WithStack(c.conn.Close())
}

// Mount mounts an export path (SD is "/B/", USB is "/C/") and returns its root
// file handle, retrying since the CDJ drops the occasional request.
func (c *Client) Mount(path string) (*FileHandle, error) {
	var err error
	for range retries {
		var fh *FileHandle
		if fh, err = c.mount(path); err == nil {
			return fh, nil
		}
	}
	return nil, errors.WithStack(err)
}

// ReadFile looks up src relative to dir and reads the whole file.
func (c *Client) ReadFile(dir *FileHandle, src string) ([]byte, error) {
	fh, attr, err := c.lookup(dir, src)
	if err != nil {
		return nil, err
	}
	if attr.Type != typeReg || attr.Size == 0 {
		return nil, errors.Errorf("not a regular file: type %d size %d", attr.Type, attr.Size)
	}

	bs := make([]byte, 0, attr.Size)
	for offset := uint32(0); offset < attr.Size; {
		want := uint32(chunkSize)
		if offset+want > attr.Size {
			want = attr.Size - offset
		}
		chunk, err := c.read(fh, offset, want)
		if err != nil {
			return nil, errors.Wrapf(err, "read at offset %d", offset)
		}
		bs = append(bs, chunk...)
		offset += uint32(len(chunk))
		if len(chunk) < int(want) {
			break
		}
	}
	return bs, nil
}

func (c *Client) mount(path string) (*FileHandle, error) {
	buf := new(bytes.Buffer)
	name := utf16le(path)
	binary.Write(buf, binary.BigEndian, uint32(len(name)))
	buf.Write(name)
	pad(buf)

	res, err := c.rpc(mountProc, mountVers, mountMNT, buf.Bytes())
	if err != nil {
		return nil, err
	}
	if len(res) < 60 {
		return nil, errors.WithStack(ErrNoMount)
	}
	if status := binary.BigEndian.Uint32(res[24:28]); status != 0 {
		return nil, errors.Errorf("mount failed with status %d", status)
	}

	var fh FileHandle
	copy(fh[:], res[28:60])
	return &fh, nil
}

func (c *Client) lookup(dir *FileHandle, path string) (*FileHandle, *Attr, error) {
	buf := new(bytes.Buffer)
	name := utf16le(path)
	buf.Write(dir[:])
	binary.Write(buf, binary.BigEndian, uint32(len(name)))
	buf.Write(name)
	pad(buf)

	res, err := c.rpc(nfsProc, nfsVers, nfsLOOKUP, buf.Bytes())
	if err != nil {
		return nil, nil, err
	}
	if len(res) < 60 {
		return nil, nil, errors.New("lookup response too short")
	}
	switch status := binary.BigEndian.Uint32(res[24:28]); status {
	case 0:
	case 2:
		return nil, nil, errors.WithStack(fs.ErrNotExist)
	default:
		return nil, nil, errors.Errorf("lookup failed with status %d", status)
	}

	var fh FileHandle
	copy(fh[:], res[28:60])
	attr, err := c.attr(&fh)
	if err != nil {
		return nil, nil, err
	}
	return &fh, attr, nil
}

func (c *Client) attr(fh *FileHandle) (*Attr, error) {
	res, err := c.rpc(nfsProc, nfsVers, nfsGETATTR, fh[:])
	if err != nil {
		return nil, err
	}
	if len(res) < 28+24 {
		return nil, errors.New("getattr response too short")
	}
	if status := binary.BigEndian.Uint32(res[24:28]); status != 0 {
		return nil, errors.Errorf("getattr failed with status %d", status)
	}
	return &Attr{
		Type: binary.BigEndian.Uint32(res[28:32]),
		Size: binary.BigEndian.Uint32(res[48:52]),
	}, nil
}

func (c *Client) read(fh *FileHandle, offset, count uint32) ([]byte, error) {
	buf := new(bytes.Buffer)
	buf.Write(fh[:])
	binary.Write(buf, binary.BigEndian, offset)
	binary.Write(buf, binary.BigEndian, count)

	var res []byte
	var err error
	for range retries {
		if res, err = c.rpc(nfsProc, nfsVers, nfsREAD, buf.Bytes()); err == nil {
			break
		}
	}
	if err != nil {
		return nil, err
	}

	const start = 28 + 68 + 4
	if len(res) < start {
		return nil, errors.New("read response too short")
	}
	if status := binary.BigEndian.Uint32(res[24:28]); status != 0 {
		return nil, errors.Errorf("read failed with status %d", status)
	}

	n := binary.BigEndian.Uint32(res[start-4 : start])
	if int(n) > len(res)-start {
		n = uint32(len(res) - start)
	}
	return res[start : start+int(n)], nil
}

func (c *Client) rpc(program, version, proc uint32, data []byte) ([]byte, error) {
	c.xid++
	buf := new(bytes.Buffer)
	binary.Write(buf, binary.BigEndian, header{
		XID:      c.xid,
		MsgType:  rpcCall,
		RPCVers:  2,
		Program:  program,
		Version:  version,
		Proc:     proc,
		AuthType: authNull,
		VerfType: authNull,
	})
	buf.Write(data)

	if _, err := c.conn.Write(buf.Bytes()); err != nil {
		return nil, errors.WithStack(err)
	}

	res := make([]byte, 8192)
	c.conn.SetReadDeadline(time.Now().Add(5 * time.Second))
	n, err := c.conn.Read(res)
	if err != nil {
		return nil, errors.WithStack(err)
	}
	return res[:n], nil
}

// utf16le encodes a path as Pioneer expects, UTF-16 little-endian.
func utf16le(path string) []byte {
	p := utf16.Encode([]rune(path))
	bs := make([]byte, len(p)*2)
	for i, v := range p {
		binary.LittleEndian.PutUint16(bs[i*2:], v)
	}
	return bs
}

func pad(buf *bytes.Buffer) {
	for buf.Len()%4 != 0 {
		buf.WriteByte(0)
	}
}


File created successfully at: /Users/noah/conductor/workspaces/deadca7/san-diego/link/nfs/nfs.go (file state is current in your context — no need to Read it back)

// Package nfs is a minimal NFSv2 client for reading files off a Pioneer CDJ.
// The CDJ serves both the mount and NFS RPC programs on UDP 2049, so no
// portmap step is needed; paths and lookup names are UTF-16LE.
package nfs

import (
	"bytes"
	"encoding/binary"
	"io/fs"
	"net"
	"time"
	"unicode/utf16"

	"github.com/cockroachdb/errors"
)

const (
	authNull = 0

	mountProc = 100005
	mountVers = 1
	mountMNT  = 1

	nfsProc    = 100003
	nfsVers    = 2
	nfsGETATTR = 1
	nfsLOOKUP  = 4
	nfsREAD    = 6

	rpcCall = 0

	typeReg = 1

	chunkSize = 8092
	retries   = 5
)

// ErrNoMount is returned when the CDJ does not answer a mount request.
var ErrNoMount = errors.New("no mount response")

// Attr holds the NFS file attributes we parse; only Size and Type are used.
type Attr struct {
	Size uint32
	Type uint32
}

// FileHandle is an opaque NFSv2 32-byte handle.
type FileHandle [32]byte

// Client is a connected NFS client for one CDJ.
type Client struct {
	addr *net.UDPAddr
	conn *net.UDPConn
	xid  uint32
}

type header struct {
	XID      uint32
	MsgType  uint32
	RPCVers  uint32
	Program  uint32
	Version  uint32
	Proc     uint32
	AuthType uint32
	AuthLen  uint32
	VerfType uint32
	VerfLen  uint32
}

// New dials the CDJ's NFS service, e.g. "192.168.0.192:2049".
func New(address string) (*Client, error) {
	addr, err := net.ResolveUDPAddr("udp", address)
	if err != nil {
		return nil, errors.WithStack(err)
	}

	conn, err := net.DialUDP("udp", nil, addr)
	if err != nil {
		return nil, errors.WithStack(err)
	}

	return &Client{addr: addr, conn: conn, xid: uint32(time.Now().Unix())}, nil
}

func (c *Client) Close() error {
	return errors.WithStack(c.conn.Close())
}

// Mount mounts an export path (SD is "/B/", USB is "/C/") and returns its root
// file handle, retrying since the CDJ drops the occasional request.
func (c *Client) Mount(path string) (*FileHandle, error) {
	var err error
	for range retries {
		var fh *FileHandle
		if fh, err = c.mount(path); err == nil {
			return fh, nil
		}
	}
	return nil, errors.WithStack(err)
}

// ReadFile looks up src relative to dir and reads the whole file.
func (c *Client) ReadFile(dir *FileHandle, src string) ([]byte, error) {
	fh, attr, err := c.lookup(dir, src)
	if err != nil {
		return nil, err
	}
	if attr.Type != typeReg || attr.Size == 0 {
		return nil, errors.Errorf("not a regular file: type %d size %d", attr.Type, attr.Size)
	}

	bs := make([]byte, 0, attr.Size)
	for offset := uint32(0); offset < attr.Size; {
		want := uint32(chunkSize)
		if offset+want > attr.Size {
			want = attr.Size - offset
		}
		chunk, err := c.read(fh, offset, want)
		if err != nil {
			return nil, errors.Wrapf(err, "read at offset %d", offset)
		}
		bs = append(bs, chunk...)
		offset += uint32(len(chunk))
		if len(chunk) < int(want) {
			break
		}
	}
	return bs, nil
}

func (c *Client) mount(path string) (*FileHandle, error) {
	buf := new(bytes.Buffer)
	name := utf16le(path)
	binary.Write(buf, binary.BigEndian, uint32(len(name)))
	buf.Write(name)
	pad(buf)

	res, err := c.rpc(mountProc, mountVers, mountMNT, buf.Bytes())
	if err != nil {
		return nil, err
	}
	if len(res) < 60 {
		return nil, errors.WithStack(ErrNoMount)
	}
	if status := binary.BigEndian.Uint32(res[24:28]); status != 0 {
		return nil, errors.Errorf("mount failed with status %d", status)
	}

	var fh FileHandle
	copy(fh[:], res[28:60])
	return &fh, nil
}

func (c *Client) lookup(dir *FileHandle, path string) (*FileHandle, *Attr, error) {
	buf := new(bytes.Buffer)
	name := utf16le(path)
	buf.Write(dir[:])
	binary.Write(buf, binary.BigEndian, uint32(len(name)))
	buf.Write(name)
	pad(buf)

	res, err := c.rpc(nfsProc, nfsVers, nfsLOOKUP, buf.Bytes())
	if err != nil {
		return nil, nil, err
	}
	if len(res) < 60 {
		return nil, nil, errors.New("lookup response too short")
	}
	switch status := binary.BigEndian.Uint32(res[24:28]); status {
	case 0:
	case 2:
		return nil, nil, errors.WithStack(fs.ErrNotExist)
	default:
		return nil, nil, errors.Errorf("lookup failed with status %d", status)
	}

	var fh FileHandle
	copy(fh[:], res[28:60])
	attr, err := c.attr(&fh)
	if err != nil {
		return nil, nil, err
	}
	return &fh, attr, nil
}

func (c *Client) attr(fh *FileHandle) (*Attr, error) {
	res, err := c.rpc(nfsProc, nfsVers, nfsGETATTR, fh[:])
	if err != nil {
		return nil, err
	}
	if len(res) < 28+24 {
		return nil, errors.New("getattr response too short")
	}
	if status := binary.BigEndian.Uint32(res[24:28]); status != 0 {
		return nil, errors.Errorf("getattr failed with status %d", status)
	}
	return &Attr{
		Type: binary.BigEndian.Uint32(res[28:32]),
		Size: binary.BigEndian.Uint32(res[48:52]),
	}, nil
}

func (c *Client) read(fh *FileHandle, offset, count uint32) ([]byte, error) {
	buf := new(bytes.Buffer)
	buf.Write(fh[:])
	binary.Write(buf, binary.BigEndian, offset)
	binary.Write(buf, binary.BigEndian, count)

	var res []byte
	var err error
	for range retries {
		if res, err = c.rpc(nfsProc, nfsVers, nfsREAD, buf.Bytes()); err == nil {
			break
		}
	}
	if err != nil {
		return nil, err
	}

	const start = 28 + 68 + 4
	if len(res) < start {
		return nil, errors.New("read response too short")
	}
	if status := binary.BigEndian.Uint32(res[24:28]); status != 0 {
		return nil, errors.Errorf("read failed with status %d", status)
	}

	n := binary.BigEndian.Uint32(res[start-4 : start])
	if int(n) > len(res)-start {
		n = uint32(len(res) - start)
	}
	return res[start : start+int(n)], nil
}

func (c *Client) rpc(program, version, proc uint32, data []byte) ([]byte, error) {
	c.xid++
	buf := new(bytes.Buffer)
	binary.Write(buf, binary.BigEndian, header{
		XID:      c.xid,
		MsgType:  rpcCall,
		RPCVers:  2,
		Program:  program,
		Version:  version,
		Proc:     proc,
		AuthType: authNull,
		VerfType: authNull,
	})
	buf.Write(data)

	if _, err := c.conn.Write(buf.Bytes()); err != nil {
		return nil, errors.WithStack(err)
	}

	res := make([]byte, 8192)
	c.conn.SetReadDeadline(time.Now().Add(5 * time.Second))
	n, err := c.conn.Read(res)
	if err != nil {
		return nil, errors.WithStack(err)
	}
	return res[:n], nil
}

// utf16le encodes a path as Pioneer expects, UTF-16 little-endian.
func utf16le(path string) []byte {
	p := utf16.Encode([]rune(path))
	bs := make([]byte, len(p)*2)
	for i, v := range p {
		binary.LittleEndian.PutUint16(bs[i*2:], v)
	}
	return bs
}

func pad(buf *bytes.Buffer) {
	for buf.Len()%4 != 0 {
		buf.WriteByte(0)
	}
}


a, then yes next we should start on the catalog / database stuff


I need to write the cmd/fetch command to mount and fetch the export.pdb file, mapping the SD card to the /B/ slot and USB to /C/.


Now a fetch command to pull `export.pdb` off the CDJ:


// Command fetch reads a rekordbox export from a CDJ over NFS and writes it to
// disk, e.g. go run ./cmd/fetch -ip 192.168.0.192 -slot sd -out export.pdb
package main

import (
	"flag"
	"fmt"
	"os"

	"github.com/cockroachdb/errors"
	"github.com/nzoschke/deadca7/link/nfs"
)

var slots = map[string]string{
	"sd":  "/B/",
	"usb": "/C/",
}

func main() {
	ip := flag.String("ip", "", "CDJ IP address")
	out := flag.String("out", "export.pdb", "output file path")
	slot := flag.String("slot", "sd", "media slot: sd or usb")
	src := flag.String("src", "PIONEER/rekordbox/export.pdb", "path on the media")
	flag.Parse()

	if err := run(*ip, *slot, *src, *out); err != nil {
		fmt.Fprintf(os.Stderr, "%+v\n", err)
		os.Exit(1)
	}
}

func run(ip, slot, src, out string) error {
	path, ok := slots[slot]
	if !ok {
		return errors.Errorf("unknown slot %q", slot)
	}

	c, err := nfs.New(ip + ":2049")
	if err != nil {
		return err
	}
	defer c.Close()

	fh, err := c.Mount(path)
	if err != nil {
		return err
	}

	bs, err := c.ReadFile(fh, src)
	if err != nil {
		return err
	}

	if err := os.WriteFile(out, bs, 0o644); err != nil {
		return errors.WithStack(err)
	}

	fmt.Fprintf(os.Stderr, "wrote %d bytes to %s\n", len(bs), out)
	return nil
}


File created successfully at: /Users/noah/conductor/workspaces/deadca7/san-diego/link/cmd/fetch/main.go (file state is current in your context — no need to Read it back)

// Command fetch reads a rekordbox export from a CDJ over NFS and writes it to
// disk, e.g. go run ./cmd/fetch -ip 192.168.0.192 -slot sd -out export.pdb
package main

import (
	"flag"
	"fmt"
	"os"

	"github.com/cockroachdb/errors"
	"github.com/nzoschke/deadca7/link/nfs"
)

var slots = map[string]string{
	"sd":  "/B/",
	"usb": "/C/",
}

func main() {
	ip := flag.String("ip", "", "CDJ IP address")
	out := flag.String("out", "export.pdb", "output file path")
	slot := flag.String("slot", "sd", "media slot: sd or usb")
	src := flag.String("src", "PIONEER/rekordbox/export.pdb", "path on the media")
	flag.Parse()

	if err := run(*ip, *slot, *src, *out); err != nil {
		fmt.Fprintf(os.Stderr, "%+v\n", err)
		os.Exit(1)
	}
}

func run(ip, slot, src, out string) error {
	path, ok := slots[slot]
	if !ok {
		return errors.Errorf("unknown slot %q", slot)
	}

	c, err := nfs.New(ip + ":2049")
	if err != nil {
		return err
	}
	defer c.Close()

	fh, err := c.Mount(path)
	if err != nil {
		return err
	}

	bs, err := c.ReadFile(fh, src)
	if err != nil {
		return err
	}

	if err := os.WriteFile(out, bs, 0o644); err != nil {
		return errors.WithStack(err)
	}

	fmt.Fprintf(os.Stderr, "wrote %d bytes to %s\n", len(bs), out)
	return nil
}


initialization failed: packages.Load error: err: exit status 1: stderr: go: go.work requires go >= 1.26.4 (running go 1.26.0)


Bash

cd /Users/noah/conductor/workspaces/deadca7/san-diego/link && go build ./... 2>&1 | tail && echo "---FETCH export.pdb from CDJ-3000 SD---" && go run ./cmd/fetch -ip 192.168.0.192 -slot sd -out testdata/export.pdb 2>&1 && ls -la testdata/export.pdb && echo "--- first bytes ---" && xxd testdata/export.pdb | head -4

Build and fetch export.pdb over NFS


---FETCH export.pdb from CDJ-3000 SD---
wrote 184320 bytes to testdata/export.pdb
-rw-r--r--@ 1 noah  staff  184320 Jun  7 10:40 testdata/export.pdb
--- first bytes ---
00000000: 0000 0000 0010 0000 1400 0000 3700 0000  ............7...
00000010: 0400 0000 5800 0000 0000 0000 0000 0000  ....X...........
00000020: 3300 0000 0100 0000 0200 0000 0100 0000  3...............
00000030: 3000 0000 0300 0000 0400 0000 0200 0000  0...............


 I've successfully pulled the export.pdb file from the CDJ-3000's SD card over NFS—it's 184,320 bytes and the header structure confirms it's a valid rekordbox database. The initial bytes match the expected format: a zero header, a page size of 4096 bytes, and 20 tables defined, which aligns with the rekordbox PDB specification.

Now I need to tackle parsing this PDB file. I'm weighing three approaches: porting the 78k Kaitai-generated Go code from vizlab (which would require the kaitai-struct-go-runtime dependency), regenerating from crate-digger's Kaitai schema (if the compiler is available), or writing a minimal hand-rolled parser focused just on the tables I need—tracks, artists, and albums. The PDB format uses a page-based structure with DeviceSQL pages and row groups, which adds complexity, but a custom parser would be more idiomatic Go and avoid the generated-code overhead.

For this turn, I think the pragmatic move is to report the NFS fetch success and the valid export.pdb fixture, then lay out the parser options for the user. I could also start with a concrete first step: decoding the PDB header and walking the table pointers to prove I can navigate the page structure, which would be testable against the fixture and move us closer to end-to-end.

Let me parse the header structure now to show what we're working with — extracting the page size, table count, and table pointer offsets from the binary data. Rather than hand-derive the header offsets and risk errors, I'll port the proven Kaitai-generated parser from vizlab's pdb package—it's already validated against crate-digger's canonical schema. The app already uses generated code (sqlc, templ), so adding a Kaitai runtime dependency and the generated parser fits the project's norms. I'll fetch the generated rekordbox_pdb.go file and write it directly to avoid manual transcription, then fix the package imports and wrapper to match the codebase structure.


**Fetched `export.pdb` over NFS** — 184,320 bytes off the CDJ-3000's SD card, and the header is a valid rekordbox PDB (`len_page=4096`, `num_tables=20`). The full chain is now wired: status said *track 1, player 3, SD* → we mounted `/B/` on `.192` and pulled the database. Saved as `testdata/export.pdb`, so all PDB parsing is now hermetic.

Let me update `TODO.md`, then look at the PDB parser so we can decide how to port it.


Edit

- [ ] RPC portmap (port 111) — GETPORT for mount + nfs
- [ ] MOUNT — mount the media slot, obtain root file handle
- [ ] NFS — LOOKUP + READ `PIONEER/rekordbox/export.pdb`
- [ ] PDB parse: tables (tracks, artists, albums, artwork, playlists)
- [ ] ANLZ parse: `.DAT`/`.EXT` (beat grid, cue points, waveforms)
- [ ] metadata-by-track-ID resolved against captured PDB

- [x] no portmap needed: CDJ serves mount + NFS on UDP 2049 (nfs/nfs.go)
- [x] MOUNT slot path (SD=/B/, USB=/C/) → root file handle
- [x] NFS LOOKUP + READ `PIONEER/rekordbox/export.pdb` (cmd/fetch) —
      pulled 184320 bytes off CDJ-3000 SD into `testdata/export.pdb`
- [ ] PDB parse: tables (tracks, artists, albums, artwork, playlists)
- [ ] ANLZ parse: `.DAT`/`.EXT` (beat grid, cue points, waveforms)
- [ ] metadata-by-track-ID resolved against captured PDB (track 1 → title/artist)


The file /Users/noah/conductor/workspaces/deadca7/san-diego/link/TODO.md has been updated successfully. (file state is current in your context — no need to Read it back)

- [x] no portmap needed: CDJ serves mount + NFS on UDP 2049 (nfs/nfs.go)
- [x] MOUNT slot path (SD=/B/, USB=/C/) → root file handle
- [x] NFS LOOKUP + READ `PIONEER/rekordbox/export.pdb` (cmd/fetch) —
      pulled 184320 bytes off CDJ-3000 SD into `testdata/export.pdb`
- [ ] PDB parse: tables (tracks, artists, albums, artwork, playlists)
- [ ] ANLZ parse: `.DAT`/`.EXT` (beat grid, cue points, waveforms)
- [ ] metadata-by-track-ID resolved against captured PDB (track 1 → title/artist)

# link TODO

Go port of Pioneer Pro DJ Link, built capture-first: document each packet,
capture a real fixture from the CDJ-3000, then decode it under test. No test
should require live hardware — `cmd/capture` records fixtures into `testdata/`.

## Setup

- CDJ-3000 (device 3) at `192.168.0.192`, DJM-V10 mixer (device 33) at
  `192.168.0.193`, this Mac at `192.168.0.160` (en0), one `/24`.
- Reference SD card loaded in the CDJ for the database (crate-digger) flows.
- Passive capture: `go run ./cmd/capture -out testdata/<name>.jsonl -secs 8`.
- Active flows need a virtual CDJ that announces first — see Announce below.
  RPC/NFS uses dynamic ports, so capture those with `tcpdump` alongside.

## beat-link — live network protocol (UDP)

Each packet starts with Magic `51 73 70 74 31 57 6d 4a 4f 4c` + a type byte
read together with the port.

### Discovery / startup — port 50000
- [x] keep-alive (type 0x06): name, device #, IP, MAC — `testdata/devices.jsonl`
      covers CDJ-3000 (device 3) and DJM-V10 (device 33); name at 0x0c
- [x] virtual CDJ four-stage startup handshake (announce.go): hello (0x0a) ×3,
      claim 0x00/0x02/0x04 ×3 at 300ms, then keep-alive (0x06) at 1500ms.
      Verified the CDJ-3000 then streams 0x0a status — `testdata/status.jsonl`.
- [x] device-number negotiation (announce.go Online): self-assign (device 0)
      after a watch period, accept mixer assignment (0x01/0x03/0x05), abort/retry
      on a defense (0x08), and defend our own number once online. Verified fixed
      and auto-assign both reach 0x0a status; auto picked 5 (CDJ=3, mixer=33 used).
- [x] defense-out verified live: held device 4 while a second CDJ (.194) fixed
      to 4 was powered on; it oscillated 4<->25 as we defended 28 times. Real
      claim-stage-2 packets in `testdata/claim.jsonl`, parsed by ClaimNumber.
- [ ] mixer/gateway packet seen on 50002 (type 0x40, "NXS-GW") — identify & decode

### Beat sync — port 50001
- [x] beat packet (type 0x28): BPM, beat-in-bar, pitch — `testdata/beats.jsonl`
      (name@0x0b; bpm@0x5a centi; beat@0x5c; pitch@0x55 3-byte, 0x100000=100%)
- [ ] next/prev beat-timing distances (six 4-byte BE values from ~0x24)
- [ ] CDJ type 0x0b position stream (~30/s, len 60) — identify & decode
- [ ] mixer type 0x03 (~3/s) and type 0x04 (len 100) from DJM-V10 — decode
- [ ] capture across a tempo change and a loaded vs. stopped deck

### Status — port 50002 (needs virtual CDJ online)
- [x] captured CDJ 0x0a status (len 1116) — `testdata/status.jsonl`
- [x] decode 0x0a (status.go DecodeStatus): play/master/sync/on-air flags @0x89,
      bpm @0x92, pitch @0x8d, beat @0xa0, beat-in-bar @0xa6
- [x] loaded-track fields: track ID @0x2c, source player @0x28, slot @0x29,
      type @0x2a. Verified: track 1, player 3, SD, rekordbox.
- [ ] CDJ type 0x40 on 50002 — identify & decode
- [ ] capture scenarios: idle, track loaded, playing, two-deck, on-air toggle

## crate-digger — rekordbox database (over NFS)

Reached only after the virtual CDJ announces and a deck reports a loaded track.

- [ ] RPC portmap (port 111) — GETPORT for mount + nfs
- [ ] MOUNT — mount the media slot, obtain root file handle
- [ ] NFS — LOOKUP + READ `PIONEER/rekordbox/export.pdb`
- [ ] PDB parse: tables (tracks, artists, albums, artwork, playlists)
- [ ] ANLZ parse: `.DAT`/`.EXT` (beat grid, cue points, waveforms)
- [ ] metadata-by-track-ID resolved against captured PDB

## Reliability (port the vizlab code without its known bugs)

- [x] proper startup handshake instead of vizlab blasting only keep-alives —
      this was the main instability: real players need hello + claim first
- [x] SetBroadcast (SO_BROADCAST) so subnet-broadcast sends don't silently fail
- [ ] replace `time.Sleep(5s)` FIXME in `Start()` with discovery/mount readiness
- [ ] make the media query deterministic (vizlab's was "unreliable")
- [x] swap `github.com/pkg/errors` → `github.com/cockroachdb/errors`
- [ ] live integration suite gated behind `testing.Short()` / env, separate from
      the hermetic fixture-replay tests

-- [ ] RPC portmap (port 111) — GETPORT for mount + nfs

-- [ ] MOUNT — mount the media slot, obtain root file handle

-- [ ] NFS — LOOKUP + READ `PIONEER/rekordbox/export.pdb`

+- [x] no portmap needed: CDJ serves mount + NFS on UDP 2049 (nfs/nfs.go)

+- [x] MOUNT slot path (SD=/B/, USB=/C/) → root file handle

+- [x] NFS LOOKUP + READ `PIONEER/rekordbox/export.pdb` (cmd/fetch) —

+      pulled 184320 bytes off CDJ-3000 SD into `testdata/export.pdb`

-- [ ] metadata-by-track-ID resolved against captured PDB

+- [ ] metadata-by-track-ID resolved against captured PDB (track 1 → title/artist)


Bash

cd /tmp && gh api repos/nzoschke/vizlab/contents/pkg/link/pdb/pdb.go --jq '.content' 2>/dev/null | base64 -d; echo "=== rekordbox_pdb.go head (imports + types) ==="; gh api repos/nzoschke/vizlab/contents/pkg/link/pdb/rekordbox_pdb.go --jq '.content' 2>/dev/null | base64 -d | head -40

Read pdb wrapper and generated parser head


package pdb

import (
	"io"
	"os"
	"regexp"
	"strings"

	"github.com/kaitai-io/kaitai_struct_go_runtime/kaitai"
	"github.com/pkg/errors"
)

type PDB struct {
	Albums   map[uint32]Album
	Artists  map[uint32]Artist
	Artworks map[uint32]Artwork
	Tracks   map[uint32]Track

	db *RekordboxPdb
}

type Album struct {
	ID   uint32
	Name string
}

type Artist struct {
	ID   uint32
	Name string
}

type Artwork struct {
	File  string
	FileM string
	ID    uint32
}

type Track struct {
	Anlz string
	Art  string
	ArtM string
	Ext  string
	Ex2  string
	ID   uint32
	File string

	Album  string
	Artist string
	Title  string
	Year   int
}

var re = regexp.MustCompile(`(\.\w+)$`)

func New(path string) (*PDB, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, errors.WithStack(err)
	}

	db := NewRekordboxPdb(false)
	s := kaitai.NewStream(f)
	if err := db.Read(s, nil, db); err != nil {
		return nil, errors.WithStack(err)
	}

	pdb := &PDB{
		Albums:   map[uint32]Album{},
		Artists:  map[uint32]Artist{},
		Artworks: map[uint32]Artwork{},
		Tracks:   map[uint32]Track{},
		db:       db,
	}

	rows(db.Tables[RekordboxPdb_PageType__Albums], func(r RekordboxPdb_AlbumRow) error {
		n, err := r.Name()
		if err != nil {
			return errors.WithStack(err)
		}

		pdb.Albums[r.Id] = Album{
			ID:   r.Id,
			Name: str(n),
		}

		return nil
	})

	rows(db.Tables[RekordboxPdb_PageType__Artists], func(r RekordboxPdb_ArtistRow) error {
		n, err := r.Name()
		if err != nil {
			return errors.WithStack(err)
		}

		pdb.Artists[r.Id] = Artist{
			ID:   r.Id,
			Name: str(n),
		}

		return nil
	})

	rows(db.Tables[RekordboxPdb_PageType__Artwork], func(r RekordboxPdb_ArtworkRow) error {
		pdb.Artworks[r.Id] = Artwork{
			File:  str(r.Path),
			FileM: re.ReplaceAllString(str(r.Path), "_m$1"),
			ID:    r.Id,
		}

		return nil
	})

	rows(db.Tables[RekordboxPdb_PageType__Tracks], func(r RekordboxPdb_TrackRow) error {
		fp, err := r.FilePath()
		if err != nil {
			return errors.WithStack(err)
		}

		ap, err := r.AnalyzePath()
		if err != nil {
			return errors.WithStack(err)
		}

		t, err := r.Title()
		if err != nil {
			return errors.WithStack(err)
		}

		album := ""
		if al, ok := pdb.Albums[r.AlbumId]; ok {
			album = al.Name
		}

		artist := ""
		if ar, ok := pdb.Artists[r.ArtistId]; ok {
			artist = ar.Name
		}

		art := ""
		artm := ""
		if ar, ok := pdb.Artworks[r.ArtworkId]; ok {
			art = ar.File
			artm = ar.FileM
		}

		pdb.Tracks[r.Id] = Track{
			Anlz: str(ap),
			Art:  art,
			ArtM: artm,
			Ext:  strings.Replace(str(ap), ".DAT", ".EXT", 1),
			Ex2:  strings.Replace(str(ap), ".DAT", ".2EX", 1),
			ID:   r.Id,
			File: str(fp),

			Album:  album,
			Artist: artist,
			Title:  str(t),
			Year:   int(r.Year),
		}

		return nil
	})

	return pdb, nil
}

func rows[T any](t *RekordboxPdb_Table, cb func(row T) error) error {
	page, err := t.FirstPage.Body()
	if err != nil {
		return errors.WithStack(err)
	}

	for {
		page, err = page.NextPage.Body()
		if err != nil {
			if err == io.EOF {
				break
			}
			return errors.WithStack(err)
		}

		data, err := page.IsDataPage()
		if err != nil {
			return errors.WithStack(err)
		}
		if !data {
			continue
		}

		groups, err := page.RowGroups()
		if err != nil {
			return errors.WithStack(err)
		}

		for _, g := range groups {
			rows, err := g.Rows()
			if err != nil {
				return errors.WithStack(err)
			}

			for _, r := range rows {
				b, err := r.Body()
				if err != nil {
					if err == io.EOF {
						break
					}
					return errors.WithStack(err)
				}

				r, ok := b.(*T)
				if !ok {
					continue
				}

				if err := cb(*r); err != nil {
					return errors.WithStack(err)
				}
			}
		}
	}

	return nil
}

func str(s *RekordboxPdb_DeviceSqlString) string {
	switch st := s.Body.(type) {
	case *RekordboxPdb_DeviceSqlLongAscii:
		return st.Text
	case *RekordboxPdb_DeviceSqlLongUtf16le:
		return st.Text
	case *RekordboxPdb_DeviceSqlShortAscii:
		return st.Text
	default:
		return ""
	}
}
=== rekordbox_pdb.go head (imports + types) ===
// Code generated by kaitai-struct-compiler from a .ksy source file. DO NOT EDIT.

package pdb

import (
	"github.com/kaitai-io/kaitai_struct_go_runtime/kaitai"
	"bytes"
	"io"
	"golang.org/x/text/encoding/unicode"
)

/**
 * This is a relational database format designed to be efficiently used
 * by very low power devices (there were deployments on 16 bit devices
 * with 32K of RAM). Today you are most likely to encounter it within
 * the Pioneer Professional DJ ecosystem, because it is the format that
 * their rekordbox software uses to write USB and SD media which can be
 * mounted in DJ controllers and used to play and mix music.
 *
 * It has been reverse-engineered to facilitate sophisticated
 * integrations with light and laser shows, videos, and other musical
 * instruments, by supporting deep knowledge of what is playing and
 * what is coming next through monitoring the network communications of
 * the players.
 *
 * The file is divided into fixed-size blocks. The first block has a
 * header that establishes the block size, and lists the tables
 * available in the database, identifying their types and the index of
 * the first of the series of linked pages that make up that table.
 *
 * Each table is made up of a series of rows which may be spread across
 * any number of pages. The pages start with a header describing the
 * page and linking to the next page. The rest of the page is used as a
 * heap: rows are scattered around it, and located using an index
 * structure that builds backwards from the end of the page. Each row
 * of a given type has a fixed size structure which links to any
 * variable-sized strings by their offsets within the page.
 *
 * As changes are made to the table, some records may become unused,
Shell cwd was reset to /Users/noah/conductor/workspaces/deadca7/san-diego


 The PDB parser is generated by Kaitai and uses their Go runtime, while the wrapper builds lookup maps for albums, artists, artworks, and tracks with proper title and artist resolution. I'm planning to port this by adding the generated Kaitai file directly to the codebase and keeping its comments intact, then building out the wrapper functionality in the pdb package.

For the wrapper, I need to swap out the pkg/errors dependency for cockroachdb/errors and adapt the imports accordingly. The generated file itself has no codon dependency, just the Kaitai runtime and pkg/errors, so the migration is straightforward. I'll fetch the 78k generated file via GitHub's API and decode it directly into the file path, then write the wrapper manually with the necessary import changes while following the lexicographic sorting and self-documenting code style from CLAUDE.md.


The PDB parser is Kaitai-generated (from crate-digger's canonical `.ksy`) with a clean wrapper that resolves tracks→title/artist/album. This project already commits generated code (sqlc/templ in `app`), so porting the generated parser fits. Let me write the generated file directly to disk (78k — fetched, not hand-typed), then port the wrapper.


cd /Users/noah/conductor/workspaces/deadca7/san-diego/link && mkdir -p pdb && gh api repos/nzoschke/vizlab/contents/pkg/link/pdb/rekordbox_pdb.go --jq '.content' 2>/dev/null | base64 -d > pdb/rekordbox_pdb.go && head -3 pdb/rekordbox_pdb.go && wc -l pdb/rekordbox_pdb.go && grep -c 'codon\|pkg/errors' pdb/rekordbox_pdb.go

Write generated PDB parser, check for bad imports


// Code generated by kaitai-struct-compiler from a .ksy source file. DO NOT EDIT.

package pdb
    3401 pdb/rekordbox_pdb.go
0


Now I'm writing the wrapper for pdb.go using cockroachdb/errors, keeping it aligned with the original vizlab implementation since that approach is proven to work.


Generated parser is in place. Now the wrapper, ported to `cockroachdb/errors`:


Write

// Package pdb reads a rekordbox export.pdb database into resolved tracks.
package pdb

import (
	"io"
	"os"
	"regexp"
	"strings"

	"github.com/cockroachdb/errors"
	"github.com/kaitai-io/kaitai_struct_go_runtime/kaitai"
)

type Album struct {
	ID   uint32
	Name string
}

type Artist struct {
	ID   uint32
	Name string
}

type Artwork struct {
	File  string
	FileM string
	ID    uint32
}

type Track struct {
	Album  string
	Anlz   string
	Art    string
	ArtM   string
	Artist string
	Ex2    string
	Ext    string
	File   string
	ID     uint32
	Title  string
	Year   int
}

type PDB struct {
	Albums   map[uint32]Album
	Artists  map[uint32]Artist
	Artworks map[uint32]Artwork
	Tracks   map[uint32]Track

	db *RekordboxPdb
}

var suffix = regexp.MustCompile(`(\.\w+)$`)

// New reads and resolves the rekordbox database at path.
func New(path string) (*PDB, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, errors.WithStack(err)
	}
	defer f.Close()

	db := NewRekordboxPdb(false)
	if err := db.Read(kaitai.NewStream(f), nil, db); err != nil {
		return nil, errors.WithStack(err)
	}

	p := &PDB{
		Albums:   map[uint32]Album{},
		Artists:  map[uint32]Artist{},
		Artworks: map[uint32]Artwork{},
		Tracks:   map[uint32]Track{},
		db:       db,
	}

	if err := rows(db.Tables[RekordboxPdb_PageType__Albums], func(r RekordboxPdb_AlbumRow) error {
		n, err := r.Name()
		if err != nil {
			return errors.WithStack(err)
		}
		p.Albums[r.Id] = Album{ID: r.Id, Name: str(n)}
		return nil
	}); err != nil {
		return nil, err
	}

	if err := rows(db.Tables[RekordboxPdb_PageType__Artists], func(r RekordboxPdb_ArtistRow) error {
		n, err := r.Name()
		if err != nil {
			return errors.WithStack(err)
		}
		p.Artists[r.Id] = Artist{ID: r.Id, Name: str(n)}
		return nil
	}); err != nil {
		return nil, err
	}

	if err := rows(db.Tables[RekordboxPdb_PageType__Artwork], func(r RekordboxPdb_ArtworkRow) error {
		p.Artworks[r.Id] = Artwork{
			File:  str(r.Path),
			FileM: suffix.ReplaceAllString(str(r.Path), "_m$1"),
			ID:    r.Id,
		}
		return nil
	}); err != nil {
		return nil, err
	}

	if err := rows(db.Tables[RekordboxPdb_PageType__Tracks], func(r RekordboxPdb_TrackRow) error {
		fp, err := r.FilePath()
		if err != nil {
			return errors.WithStack(err)
		}
		ap, err := r.AnalyzePath()
		if err != nil {
			return errors.WithStack(err)
		}
		t, err := r.Title()
		if err != nil {
			return errors.WithStack(err)
		}

		p.Tracks[r.Id] = Track{
			Album:  p.Albums[r.AlbumId].Name,
			Anlz:   str(ap),
			Art:    p.Artworks[r.ArtworkId].File,
			ArtM:   p.Artworks[r.ArtworkId].FileM,
			Artist: p.Artists[r.ArtistId].Name,
			Ex2:    strings.Replace(str(ap), ".DAT", ".2EX", 1),
			Ext:    strings.Replace(str(ap), ".DAT", ".EXT", 1),
			File:   str(fp),
			ID:     r.Id,
			Title:  str(t),
			Year:   int(r.Year),
		}
		return nil
	}); err != nil {
		return nil, err
	}

	return p, nil
}

func rows[T any](t *RekordboxPdb_Table, cb func(row T) error) error {
	page, err := t.FirstPage.Body()
	if err != nil {
		return errors.WithStack(err)
	}

	for {
		page, err = page.NextPage.Body()
		if err != nil {
			if errors.Is(err, io.EOF) {
				return nil
			}
			return errors.WithStack(err)
		}

		data, err := page.IsDataPage()
		if err != nil {
			return errors.WithStack(err)
		}
		if !data {
			continue
		}

		groups, err := page.RowGroups()
		if err != nil {
			return errors.WithStack(err)
		}
		for _, g := range groups {
			rs, err := g.Rows()
			if err != nil {
				return errors.WithStack(err)
			}
			for _, row := range rs {
				b, err := row.Body()
				if err != nil {
					if errors.Is(err, io.EOF) {
						break
					}
					return errors.WithStack(err)
				}
				if r, ok := b.(*T); ok {
					if err := cb(*r); err != nil {
						return err
					}
				}
			}
		}
	}
}

func str(s *RekordboxPdb_DeviceSqlString) string {
	switch st := s.Body.(type) {
	case *RekordboxPdb_DeviceSqlLongAscii:
		return st.Text
	case *RekordboxPdb_DeviceSqlLongUtf16le:
		return st.Text
	case *RekordboxPdb_DeviceSqlShortAscii:
		return st.Text
	default:
		return ""
	}
}


File created successfully at: /Users/noah/conductor/workspaces/deadca7/san-diego/link/pdb/pdb.go (file state is current in your context — no need to Read it back)

// Package pdb reads a rekordbox export.pdb database into resolved tracks.
package pdb

import (
	"io"
	"os"
	"regexp"
	"strings"

	"github.com/cockroachdb/errors"
	"github.com/kaitai-io/kaitai_struct_go_runtime/kaitai"
)

type Album struct {
	ID   uint32
	Name string
}

type Artist struct {
	ID   uint32
	Name string
}

type Artwork struct {
	File  string
	FileM string
	ID    uint32
}

type Track struct {
	Album  string
	Anlz   string
	Art    string
	ArtM   string
	Artist string
	Ex2    string
	Ext    string
	File   string
	ID     uint32
	Title  string
	Year   int
}

type PDB struct {
	Albums   map[uint32]Album
	Artists  map[uint32]Artist
	Artworks map[uint32]Artwork
	Tracks   map[uint32]Track

	db *RekordboxPdb
}

var suffix = regexp.MustCompile(`(\.\w+)$`)

// New reads and resolves the rekordbox database at path.
func New(path string) (*PDB, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, errors.WithStack(err)
	}
	defer f.Close()

	db := NewRekordboxPdb(false)
	if err := db.Read(kaitai.NewStream(f), nil, db); err != nil {
		return nil, errors.WithStack(err)
	}

	p := &PDB{
		Albums:   map[uint32]Album{},
		Artists:  map[uint32]Artist{},
		Artworks: map[uint32]Artwork{},
		Tracks:   map[uint32]Track{},
		db:       db,
	}

	if err := rows(db.Tables[RekordboxPdb_PageType__Albums], func(r RekordboxPdb_AlbumRow) error {
		n, err := r.Name()
		if err != nil {
			return errors.WithStack(err)
		}
		p.Albums[r.Id] = Album{ID: r.Id, Name: str(n)}
		return nil
	}); err != nil {
		return nil, err
	}

	if err := rows(db.Tables[RekordboxPdb_PageType__Artists], func(r RekordboxPdb_ArtistRow) error {
		n, err := r.Name()
		if err != nil {
			return errors.WithStack(err)
		}
		p.Artists[r.Id] = Artist{ID: r.Id, Name: str(n)}
		return nil
	}); err != nil {
		return nil, err
	}

	if err := rows(db.Tables[RekordboxPdb_PageType__Artwork], func(r RekordboxPdb_ArtworkRow) error {
		p.Artworks[r.Id] = Artwork{
			File:  str(r.Path),
			FileM: suffix.ReplaceAllString(str(r.Path), "_m$1"),
			ID:    r.Id,
		}
		return nil
	}); err != nil {
		return nil, err
	}

	if err := rows(db.Tables[RekordboxPdb_PageType__Tracks], func(r RekordboxPdb_TrackRow) error {
		fp, err := r.FilePath()
		if err != nil {
			return errors.WithStack(err)
		}
		ap, err := r.AnalyzePath()
		if err != nil {
			return errors.WithStack(err)
		}
		t, err := r.Title()
		if err != nil {
			return errors.WithStack(err)
		}

		p.Tracks[r.Id] = Track{
			Album:  p.Albums[r.AlbumId].Name,
			Anlz:   str(ap),
			Art:    p.Artworks[r.ArtworkId].File,
			ArtM:   p.Artworks[r.ArtworkId].FileM,
			Artist: p.Artists[r.ArtistId].Name,
			Ex2:    strings.Replace(str(ap), ".DAT", ".2EX", 1),
			Ext:    strings.Replace(str(ap), ".DAT", ".EXT", 1),
			File:   str(fp),
			ID:     r.Id,
			Title:  str(t),
			Year:   int(r.Year),
		}
		return nil
	}); err != nil {
		return nil, err
	}

	return p, nil
}

func rows[T any](t *RekordboxPdb_Table, cb func(row T) error) error {
	page, err := t.FirstPage.Body()
	if err != nil {
		return errors.WithStack(err)
	}

	for {
		page, err = page.NextPage.Body()
		if err != nil {
			if errors.Is(err, io.EOF) {
				return nil
			}
			return errors.WithStack(err)
		}

		data, err := page.IsDataPage()
		if err != nil {
			return errors.WithStack(err)
		}
		if !data {
			continue
		}

		groups, err := page.RowGroups()
		if err != nil {
			return errors.WithStack(err)
		}
		for _, g := range groups {
			rs, err := g.Rows()
			if err != nil {
				return errors.WithStack(err)
			}
			for _, row := range rs {
				b, err := row.Body()
				if err != nil {
					if errors.Is(err, io.EOF) {
						break
					}
					return errors.WithStack(err)
				}
				if r, ok := b.(*T); ok {
					if err := cb(*r); err != nil {
						return err
					}
				}
			}
		}
	}
}

func str(s *RekordboxPdb_DeviceSqlString) string {
	switch st := s.Body.(type) {
	case *RekordboxPdb_DeviceSqlLongAscii:
		return st.Text
	case *RekordboxPdb_DeviceSqlLongUtf16le:
		return st.Text
	case *RekordboxPdb_DeviceSqlShortAscii:
		return st.Text
	default:
		return ""
	}
}


Bash

cd /Users/noah/conductor/workspaces/deadca7/san-diego/link && go mod tidy 2>&1 | tail -5 && go build ./... 2>&1 | tail -5 && echo "BUILD OK"

Tidy and build with kaitai dep


go: finding module for package github.com/kaitai-io/kaitai_struct_go_runtime/kaitai
go: downloading github.com/kaitai-io/kaitai_struct_go_runtime v0.11.0
go: found github.com/kaitai-io/kaitai_struct_go_runtime/kaitai in github.com/kaitai-io/kaitai_struct_go_runtime v0.11.0
BUILD OK


I need to write a test that parses the PDB fixture file and validates that track 1 has the expected title and artist, but I realize I don't actually know what those values should be from the reference data, so I'll need to examine the file first to see what's there.


Builds with the kaitai runtime. Let me parse the real fixture and see what's in it before writing assertions:


cat > /tmp/explore_test.go <<'EOF'
package pdb
import ("fmt";"testing")
func TestExplore(t *testing.T) {
	p, err := New("../testdata/export.pdb")
	if err != nil { t.Fatal(err) }
	fmt.Printf("tracks=%d artists=%d albums=%d artworks=%d\n", len(p.Tracks), len(p.Artists), len(p.Albums), len(p.Artworks))
	for id, tr := range p.Tracks {
		fmt.Printf("  track %d: %q artist=%q album=%q year=%d file=%q anlz=%q\n", id, tr.Title, tr.Artist, tr.Album, tr.Year, tr.File, tr.Anlz)
	}
}
EOF
cp /tmp/explore_test.go pdb/explore_test.go && go test ./pdb/ -run TestExplore -v 2>&1 | head -40; rm pdb/explore_test.go

Explore parsed PDB contents


=== RUN   TestExplore
tracks=4 artists=1 albums=1 artworks=4
  track 3: "Houses" artist="mobygratis" album="catalog" year=0 file="/Contents/mobygratis/catalog/houses.mp3" anlz="/PIONEER/USBANLZ/P054/00018252/ANLZ0000.DAT"
  track 1: "get it" artist="mobygratis" album="catalog" year=2020 file="/Contents/mobygratis/catalog/get-it.mp3" anlz="/PIONEER/USBANLZ/P05C/00011ED2/ANLZ0000.DAT"
  track 2: "new key" artist="mobygratis" album="catalog" year=2020 file="/Contents/mobygratis/catalog/new-key.mp3" anlz="/PIONEER/USBANLZ/P00C/000249F8/ANLZ0000.DAT"
  track 4: "out stands" artist="mobygratis" album="catalog" year=2020 file="/Contents/mobygratis/catalog/out-stands.mp3" anlz="/PIONEER/USBANLZ/P03F/0002F7F5/ANLZ0000.DAT"
--- PASS: TestExplore (0.00s)
PASS
ok  	github.com/nzoschke/deadca7/link/pdb	0.206s


 The end-to-end PDB parser is working correctly—the status shows track 1, which fetches from the NFS export and parses to "get it" by mobygratis from the 2020 catalog. Now I'm writing a hermetic test that asserts all these details match: track ID, artist, album, year, and file path.

Writing test assertions...


The full chain works end-to-end. The reference SD card holds 4 tracks from Moby's free **mobygratis** catalog, and **track 1 = "get it" by mobygratis** — exactly the track ID our status decode reported. Let me lock it in with a hermetic test.


Write

package pdb

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestNew(t *testing.T) {
	a := assert.New(t)
	r := require.New(t)

	p, err := New("../testdata/export.pdb")
	r.NoError(err)

	a.Len(p.Tracks, 4)

	tests := []struct {
		id     uint32
		title  string
		artist string
		album  string
		year   int
		file   string
	}{
		{id: 1, title: "get it", artist: "mobygratis", album: "catalog", year: 2020, file: "/Contents/mobygratis/catalog/get-it.mp3"},
		{id: 2, title: "new key", artist: "mobygratis", album: "catalog", year: 2020, file: "/Contents/mobygratis/catalog/new-key.mp3"},
		{id: 3, title: "Houses", artist: "mobygratis", album: "catalog", file: "/Contents/mobygratis/catalog/houses.mp3"},
		{id: 4, title: "out stands", artist: "mobygratis", album: "catalog", year: 2020, file: "/Contents/mobygratis/catalog/out-stands.mp3"},
	}

	for _, tt := range tests {
		t.Run(tt.title, func(t *testing.T) {
			a := assert.New(t)
			tr, ok := p.Tracks[tt.id]
			a.True(ok)
			a.Equal(tt.title, tr.Title)
			a.Equal(tt.artist, tr.Artist)
			a.Equal(tt.album, tr.Album)
			a.Equal(tt.year, tr.Year)
			a.Equal(tt.file, tr.File)
			a.NotEmpty(tr.Anlz)
		})
	}
}


File created successfully at: /Users/noah/conductor/workspaces/deadca7/san-diego/link/pdb/pdb_test.go (file state is current in your context — no need to Read it back)

package pdb

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestNew(t *testing.T) {
	a := assert.New(t)
	r := require.New(t)

	p, err := New("../testdata/export.pdb")
	r.NoError(err)

	a.Len(p.Tracks, 4)

	tests := []struct {
		id     uint32
		title  string
		artist string
		album  string
		year   int
		file   string
	}{
		{id: 1, title: "get it", artist: "mobygratis", album: "catalog", year: 2020, file: "/Contents/mobygratis/catalog/get-it.mp3"},
		{id: 2, title: "new key", artist: "mobygratis", album: "catalog", year: 2020, file: "/Contents/mobygratis/catalog/new-key.mp3"},
		{id: 3, title: "Houses", artist: "mobygratis", album: "catalog", file: "/Contents/mobygratis/catalog/houses.mp3"},
		{id: 4, title: "out stands", artist: "mobygratis", album: "catalog", year: 2020, file: "/Contents/mobygratis/catalog/out-stands.mp3"},
	}

	for _, tt := range tests {
		t.Run(tt.title, func(t *testing.T) {
			a := assert.New(t)
			tr, ok := p.Tracks[tt.id]
			a.True(ok)
			a.Equal(tt.title, tr.Title)
			a.Equal(tt.artist, tr.Artist)
			a.Equal(tt.album, tr.Album)
			a.Equal(tt.year, tr.Year)
			a.Equal(tt.file, tr.File)
			a.NotEmpty(tr.Anlz)
		})
	}
}


Bash

Vet and run all link module tests


pdb/rekordbox_pdb.go:1307:2: self-assignment of tmp77
pdb/rekordbox_pdb.go:1681:2: self-assignment of tmp101
pdb/rekordbox_pdb.go:3017:2: self-assignment of tmp187
---TEST ALL---
ok  	github.com/nzoschke/deadca7/link	(cached)
?   	github.com/nzoschke/deadca7/link/cmd/capture	[no test files]
?   	github.com/nzoschke/deadca7/link/cmd/fetch	[no test files]
?   	github.com/nzoschke/deadca7/link/nfs	[no test files]
ok  	github.com/nzoschke/deadca7/link/pdb	0.231s


 All the tests are passing, though `go vet` is picking up some self-assignment warnings in the generated rekordbox_pdb.go file. These are artifacts from the Kaitai code generator, not something I wrote, and they're just warnings rather than actual errors. Since the file is marked as generated code that shouldn't be manually edited, I'm weighing whether to leave them as-is or find a way to suppress the noise.

The real concern is whether `go vet ./...` failing in CI would be a blocker. I could regenerate the file with a newer Kaitai compiler, but that's beyond the current scope. I'll document this and move forward with marking the PDB parsing and metadata resolution as complete in the TODO.


All tests pass — the database chain is complete. The `go vet` warnings (`self-assignment of tmp77…`) are inside the **generated** `rekordbox_pdb.go` (marked `DO NOT EDIT`), a known kaitai-compiler artifact — not our code, and harmless. Let me update `TODO.md`.


Edit

- [ ] PDB parse: tables (tracks, artists, albums, artwork, playlists)
- [ ] ANLZ parse: `.DAT`/`.EXT` (beat grid, cue points, waveforms)
- [ ] metadata-by-track-ID resolved against captured PDB (track 1 → title/artist)

- [x] PDB parse (pdb/): kaitai-generated rekordbox_pdb.go + wrapper resolving
      tracks->title/artist/album/artwork/anlz. Tested vs `testdata/export.pdb`.
      (go vet warns on self-assignment in the generated file; upstream artifact)
- [x] metadata-by-track-ID: track 1 -> "get it" by mobygratis, matches status
- [ ] playlists / playlist tree
- [ ] ANLZ parse: `.DAT`/`.EXT` (beat grid, cue points, waveforms)
- [ ] wire status track ID + slot -> NFS fetch -> PDB lookup as one Link call


- [x] PDB parse (pdb/): kaitai-generated rekordbox_pdb.go + wrapper resolving
      tracks->title/artist/album/artwork/anlz. Tested vs `testdata/export.pdb`.
      (go vet warns on self-assignment in the generated file; upstream artifact)
- [x] metadata-by-track-ID: track 1 -> "get it" by mobygratis, matches status
- [ ] playlists / playlist tree
- [ ] ANLZ parse: `.DAT`/`.EXT` (beat grid, cue points, waveforms)
- [ ] wire status track ID + slot -> NFS fetch -> PDB lookup as one Link call

# link TODO

Go port of Pioneer Pro DJ Link, built capture-first: document each packet,
capture a real fixture from the CDJ-3000, then decode it under test. No test
should require live hardware — `cmd/capture` records fixtures into `testdata/`.

## Setup

- CDJ-3000 (device 3) at `192.168.0.192`, DJM-V10 mixer (device 33) at
  `192.168.0.193`, this Mac at `192.168.0.160` (en0), one `/24`.
- Reference SD card loaded in the CDJ for the database (crate-digger) flows.
- Passive capture: `go run ./cmd/capture -out testdata/<name>.jsonl -secs 8`.
- Active flows need a virtual CDJ that announces first — see Announce below.
  RPC/NFS uses dynamic ports, so capture those with `tcpdump` alongside.

## beat-link — live network protocol (UDP)

Each packet starts with Magic `51 73 70 74 31 57 6d 4a 4f 4c` + a type byte
read together with the port.

### Discovery / startup — port 50000
- [x] keep-alive (type 0x06): name, device #, IP, MAC — `testdata/devices.jsonl`
      covers CDJ-3000 (device 3) and DJM-V10 (device 33); name at 0x0c
- [x] virtual CDJ four-stage startup handshake (announce.go): hello (0x0a) ×3,
      claim 0x00/0x02/0x04 ×3 at 300ms, then keep-alive (0x06) at 1500ms.
      Verified the CDJ-3000 then streams 0x0a status — `testdata/status.jsonl`.
- [x] device-number negotiation (announce.go Online): self-assign (device 0)
      after a watch period, accept mixer assignment (0x01/0x03/0x05), abort/retry
      on a defense (0x08), and defend our own number once online. Verified fixed
      and auto-assign both reach 0x0a status; auto picked 5 (CDJ=3, mixer=33 used).
- [x] defense-out verified live: held device 4 while a second CDJ (.194) fixed
      to 4 was powered on; it oscillated 4<->25 as we defended 28 times. Real
      claim-stage-2 packets in `testdata/claim.jsonl`, parsed by ClaimNumber.
- [ ] mixer/gateway packet seen on 50002 (type 0x40, "NXS-GW") — identify & decode

### Beat sync — port 50001
- [x] beat packet (type 0x28): BPM, beat-in-bar, pitch — `testdata/beats.jsonl`
      (name@0x0b; bpm@0x5a centi; beat@0x5c; pitch@0x55 3-byte, 0x100000=100%)
- [ ] next/prev beat-timing distances (six 4-byte BE values from ~0x24)
- [ ] CDJ type 0x0b position stream (~30/s, len 60) — identify & decode
- [ ] mixer type 0x03 (~3/s) and type 0x04 (len 100) from DJM-V10 — decode
- [ ] capture across a tempo change and a loaded vs. stopped deck

### Status — port 50002 (needs virtual CDJ online)
- [x] captured CDJ 0x0a status (len 1116) — `testdata/status.jsonl`
- [x] decode 0x0a (status.go DecodeStatus): play/master/sync/on-air flags @0x89,
      bpm @0x92, pitch @0x8d, beat @0xa0, beat-in-bar @0xa6
- [x] loaded-track fields: track ID @0x2c, source player @0x28, slot @0x29,
      type @0x2a. Verified: track 1, player 3, SD, rekordbox.
- [ ] CDJ type 0x40 on 50002 — identify & decode
- [ ] capture scenarios: idle, track loaded, playing, two-deck, on-air toggle

## crate-digger — rekordbox database (over NFS)

Reached only after the virtual CDJ announces and a deck reports a loaded track.

- [x] no portmap needed: CDJ serves mount + NFS on UDP 2049 (nfs/nfs.go)
- [x] MOUNT slot path (SD=/B/, USB=/C/) → root file handle
- [x] NFS LOOKUP + READ `PIONEER/rekordbox/export.pdb` (cmd/fetch) —
      pulled 184320 bytes off CDJ-3000 SD into `testdata/export.pdb`
- [ ] PDB parse: tables (tracks, artists, albums, artwork, playlists)
- [ ] ANLZ parse: `.DAT`/`.EXT` (beat grid, cue points, waveforms)
- [ ] metadata-by-track-ID resolved against captured PDB (track 1 → title/artist)

## Reliability (port the vizlab code without its known bugs)

- [x] proper startup handshake instead of vizlab blasting only keep-alives —
      this was the main instability: real players need hello + claim first
- [x] SetBroadcast (SO_BROADCAST) so subnet-broadcast sends don't silently fail
- [ ] replace `time.Sleep(5s)` FIXME in `Start()` with discovery/mount readiness
- [ ] make the media query deterministic (vizlab's was "unreliable")
- [x] swap `github.com/pkg/errors` → `github.com/cockroachdb/errors`
- [ ] live integration suite gated behind `testing.Short()` / env, separate from
      the hermetic fixture-replay tests

-- [ ] PDB parse: tables (tracks, artists, albums, artwork, playlists)

+- [x] PDB parse (pdb/): kaitai-generated rekordbox_pdb.go + wrapper resolving

+      tracks->title/artist/album/artwork/anlz. Tested vs `testdata/export.pdb`.

+      (go vet warns on self-assignment in the generated file; upstream artifact)

+- [x] metadata-by-track-ID: track 1 -> "get it" by mobygratis, matches status

-- [ ] metadata-by-track-ID resolved against captured PDB (track 1 → title/artist)

+- [ ] wire status track ID + slot -> NFS fetch -> PDB lookup as one Link call


Both parts are done, and the entire pipeline is now proven end-to-end against real hardware.

## (a) Status decoder — done
`status.go` `DecodeStatus` parses the `0x0a` CDJ status: play/master/sync/on-air flags, BPM, pitch, beat + beat-in-bar, and the loaded-track identity (**track ID, source player, slot, type**). `TestDecodeStatus` verifies against the real fixture: track 1, player 3, SD, rekordbox, BPM 100, master+on-air.

## (b) Catalog / database — first slice done
The full crate-digger chain now works:

1. **NFS client** (`nfs/nfs.go`) — minimal NFSv2 over UDP 2049 (no portmap; Pioneer serves mount+nfs on one port), UTF-16LE paths, mount + chunked read.
2. **`cmd/fetch`** — pulled `PIONEER/rekordbox/export.pdb` off the CDJ-3000 SD (`/B/` on `.192`): **184,320 bytes → `testdata/export.pdb`**.
3. **PDB parser** (`pdb/`) — kaitai-generated `rekordbox_pdb.go` (from crate-digger's canonical `.ksy`) + a clean wrapper resolving tracks to title/artist/album/artwork/anlz paths.

**The chain closes:** status reported *track 1, SD* → we fetched and parsed the DB → **track 1 = "get it" by mobygratis** (album "catalog", 2020). `TestNew` asserts all 4 tracks of the reference card.

All `link` tests pass. One honest note: `go vet ./...` warns about self-assignments inside the **generated** `rekordbox_pdb.go` — an upstream kaitai-compiler artifact in a `DO NOT EDIT` file, not our code. If the lint noise bothers CI later, we can regenerate from the `.ksy` with a newer compiler.

## Where this leaves the `link` module
```
announce.go  net.go         — virtual CDJ: full startup handshake + device-number negotiation
packet.go    status.go      — keep-alive, beat, and CDJ status decoders
nfs/         pdb/           — NFS client + rekordbox DB parser
cmd/capture  cmd/fetch      — fixture capture + DB fetch tools
testdata/                   — real captures: devices, beats, status, claim, export.pdb
TODO.md                     — protocol checklist, mostly checked
```

Natural next steps (in `TODO.md`): **(1)** a single `Link` call that ties status → NFS fetch → PDB lookup so a loaded track auto-resolves to metadata; **(2)** ANLZ parsing for waveforms/beatgrids; **(3)** playlists. Want me to wire up the integrated status→metadata flow next?


lets focus on network capture, anything else in the prototol to get


 I'm thinking through what packet types we've captured so far on port 50000 and which ones still need decoding — we've got the keepalive and hello messages figured out, along with claim and defense packets, but there are others like the assign-finished and will-assign messages that we haven't fully decoded yet.

On the other ports, there's more to unpack: the precise position updates on port 50001 are coming in at a high rate and would be useful for tight synchronization, the mixer status packets (both 0x03 and 0x04) that tell us about channels and BPM, and that mysterious 0x40 gateway packet on port 50002. I'm also realizing that some of what we thought were mixer packets might actually be fader start and channels-on-air commands that the DJM broadcasts to coordinate with the CDJs.

There are plenty of scenarios we haven't captured yet—tempo changes, multiple decks playing, crossfader movements, track loads, cue/loop actions, master device handoffs, and sync toggles. The dbserver metadata protocol exists too, but that's TCP and request-response, so it's less relevant for the broadcast capture focus. For the visualization project, the highest-value remaining pieces are the precise position data for smooth playback scrubbing and the authoritative mixer channels-on-air state to show which decks are actually audible.

I should do a comprehensive inventory of what we've already captured in the existing fixtures—status.jsonl and beats.jsonl—to see which packet types we have raw data for but haven't decoded yet, then identify the gaps and plan targeted captures for the missing dynamic scenarios.


Good focus. Let me inventory every packet type we've already captured (raw) versus what we've decoded, so we can see exactly what's left in the protocol to get.


Bash

cd /Users/noah/conductor/workspaces/deadca7/san-diego/link
python3 -c '
import json,glob,collections
seen=collections.OrderedDict()
for fn in sorted(glob.glob("testdata/*.jsonl")):
    for l in open(fn):
        p=json.loads(l); d=bytes.fromhex(p["data"])
        if d[:10]!=bytes.fromhex("5173707431576d4a4f4c") or len(d)<0x0b: continue
        key=(p["port"],d[0x0a])
        e=seen.setdefault(key,{"lens":set(),"srcs":set(),"n":0})
        e["lens"].add(p["len"]); e["srcs"].add(p["src"].split(":")[0]); e["n"]+=1
print("port  type  count  lens          sources")
for (port,typ),e in sorted(seen.items()):
    print("%5d 0x%02x %6d  %-13s %s"%(port,typ,e["n"],",".join(map(str,sorted(e["lens"]))),",".join(sorted(e["srcs"]))))
'

Inventory all captured packet types across fixtures


port  type  count  lens          sources
50000 0x00      3  44            192.168.0.160
50000 0x02      5  50            192.168.0.160,192.168.0.194
50000 0x04      3  38            192.168.0.160
50000 0x05      3  38            192.168.0.193
50000 0x06     82  54            192.168.0.160,192.168.0.192,192.168.0.193
50000 0x0a      3  38            192.168.0.160
50001 0x03    262  53            192.168.0.193
50001 0x04      6  100           192.168.0.193
50001 0x0b    273  60            192.168.0.192
50001 0x28     28  96            192.168.0.192
50002 0x0a     49  1116          192.168.0.192
50002 0x40     39  38            192.168.0.192


look at https://djl-analysis.deepsymmetry.org/djl-analysis/packets.html too


 I'm cataloging the packet types across the discovery port, mapping each message ID to its corresponding beat-link protocol handler and noting which ones are already decoded versus still pending implementation.

On the beat/sync port, I see channels-on-air (0x03) from the mixer which tells us which decks are active—that's definitely worth decoding. There's also a 0x04 packet from the mixer at 100 bytes that I'm not immediately certain about; it could be mixer status or BPM data but I need to dig deeper. The precise position packets (0x0b) from CDJs are high-value for visualization since they show playback position, and the beat packets (0x28) are already handled.

On the status port, I've got the standard 0x0a status decoded, but there's a 0x40 packet from the CDJ that doesn't match any beat-link type I recognize—it's low frequency and might be CDJ-3000-specific or related to that NXS-GW device name we spotted earlier.

The priority list is clear: decode precise position and channels-on-air first since they're high-value, then investigate those mystery packets. Beyond that, I should capture fader start commands, get a playing deck to validate the playing state and see position advance, test with both CDJs now that the second one is available, and explore master handoff and sync behavior.

For the capture itself, I'll run a dynamic session with both decks online—playing tracks, adjusting pitch, toggling channels on-air, moving crossfaders—to get a single rich capture containing all the packet types. Then I'll reference beat-link's source to pull the exact layouts for precise position, channels-on-air, and those unknown mixer packets. I'll set device 5 for the virtual CDJ to avoid conflicts with the second one that's fixed to 4. I'll start a 100-second background capture to testdata/session.jsonl and fetch the beat-link layouts for the undecoded packet types in parallel, then give the user an action checklist while everything runs.


Clear picture now. Here's what's **decoded** vs **still in the protocol to get**:

| Port | Type | What it is | Status |
|---|---|---|---|
| 50000 | 0x06 / 0x00·02·04·05·08·0a | keep-alive / startup negotiation | ✅ decoded |
| 50001 | 0x28 | beat | ✅ decoded |
| 50002 | 0x0a | CDJ status | ✅ decoded |
| **50001** | **0x0b** | **precise position** (CDJ, ~30/s) | ❌ not decoded — key for visualization |
| **50001** | **0x03** | **channels-on-air** (mixer) | ❌ not decoded — authoritative on-air |
| **50001** | **0x04** | mixer packet (len 100) | ❌ unidentified |
| **50002** | **0x40** | CDJ packet "NXS-GW" (len 38) | ❌ unidentified |

Plus **scenarios** we've never captured: a *playing* deck (our status fixture was paused), **two decks at once**, tempo changes, on-air toggling via the crossfader, and track-load events.

Since you've got both CDJs + the mixer, one rich interactive session captures all of it. Let me start a 100s capture and meanwhile pull the beat-link layouts for the undecoded types so I'm ready to decode immediately.


go run ./cmd/capture -announce -device 5 -out testdata/session.jsonl -secs 100 2>&1; echo "EXIT=$?"

Rich 100s dynamic session capture


Command running in background with ID: bqe9dmr1n. Output is being written to: /private/tmp/claude-501/-Users-noah-conductor-workspaces-deadca7-san-diego/5e143c63-1d6b-4233-91af-bb63e39e8beb/tasks/bqe9dmr1n.output. You will be notified when it completes. To check interim output, use Read on that file path.


cd /tmp && gh api repos/Deep-Symmetry/beat-link/contents/src/main/java/org/deepsymmetry/beatlink/BeatFinder.java --jq '.content' 2>/dev/null | base64 -d > bf.java; echo "=== on-air / precise-position parsing ==="; grep -nE 'CHANNELS_ON_AIR|PRECISE_POSITION|onAir|0x0b|0x03|setOnAir|playbackPosition|getData\(\)\[0x' bf.java | head -40

Fetch BeatFinder for on-air/precise-position layouts


=== on-air / precise-position parsing ===
150:        return Arrays.equals(data, 0x0b, 0x0b + len, name, 0, len) &&
151:                data[0x0b + len + 1] == 0;
193:                                    case PRECISE_POSITION:
199:                                    case CHANNELS_ON_AIR:
212:                                            deliverSyncCommand(packet.getData()[0x2b]);
218:                                            deliverMasterYieldCommand(packet.getData()[0x21]);
643:    private final Set<OnAirListener> onAirListeners = Collections.newSetFromMap(new ConcurrentHashMap<>());
664:            onAirListeners.add(listener);
678:            onAirListeners.remove(listener);
690:        return Set.copyOf(onAirListeners);
Shell cwd was reset to /Users/noah/conductor/workspaces/deadca7/san-diego


Prompt: look at https://djl-analysis.deepsymmetry.org/djl-analysis/packets.html too


The capture is running, and I need to examine the djl-analysis packets.html page to understand the protocol details. I'm looking at the BeatFinder.java parsing logic around the packet handling sections and pulling the packets.html documentation to decode the specific packet types—the precise position field at 0x0b, the channels on air at 0x03, the mixer at 0x04, and the 0x40 type.


Capture is running. Let me read the BeatFinder packet handling and pull the djl-analysis packets page the user pointed to.


Read BeatFinder packet dispatch


                    }
                    try {
                        if (received) {
                            final Util.PacketType kind = Util.validateHeader(packet,  BEAT_PORT);
                            if (kind != null) {
                                switch (kind) {

                                    case BEAT:
                                        if (isPacketLongEnough(packet, 96, "beat")) {
                                            deliverBeat(new Beat(packet));
                                        }
                                        break;

                                    case PRECISE_POSITION:
                                        if (isPacketLongEnough(packet, 60, "precise position")) {
                                            deliverPrecisePosition(new PrecisePosition(packet));
                                        }
                                        break;

                                    case CHANNELS_ON_AIR:
                                        if (packet.getLength() == 0x35 ||  // New DJM-V10 packet with six channels
                                                isPacketLongEnough(packet, 0x2d, "channels on-air")) {
                                            final Set<Integer> audibleChannels = getAudibleChannels(packet);
                                            deliverOnAirUpdate(audibleChannels);
                                            if (isFromXdjAz(packet)) {  // Record that we saw an XDJ-AZ channels-on-air packet
                                                lastSeenXdjAzChannelsOnAir.set(System.nanoTime());
                                            }
                                        }
                                        break;

                                    case SYNC_CONTROL:
                                        if (isPacketLongEnough(packet, 0x2c, "sync control command")) {
                                            deliverSyncCommand(packet.getData()[0x2b]);
                                        }
                                        break;

                                    case MASTER_HANDOFF_REQUEST:
                                        if (isPacketLongEnough(packet, 0x28, "tempo master handoff request")) {
                                            deliverMasterYieldCommand(packet.getData()[0x21]);
                                        }
                                        break;

                                    case MASTER_HANDOFF_RESPONSE:
                                        if (isPacketLongEnough(packet, 0x2c, "tempo master handoff response")) {
                                            byte[] data = packet.getData();
                                            deliverMasterYieldResponse(data[0x21], data[0x2b] == 1);
                                        }
                                        break;

                                    case FADER_START_COMMAND:
                                        if (isPacketLongEnough(packet, 0x28, "fader start command")) {
Shell cwd was reset to /Users/noah/conductor/workspaces/deadca7/san-diego


WebFetch

Prompt: Give the exact byte layouts for these DJ Link packets: (1) the "channels on air" packet sent by a mixer on port 50001 (type 0x03) — which bytes indicate which channels are on air; (2) the "precise position" / "position" packet on port 50001 (type 0x0b) — byte offsets for playback position in milliseconds, track length, pitch/playback speed, and device number; (3) any packet of type 0x40 seen on port 50002. For each, list the offset and meaning of each field including the device number offset and the length field.


I cannot provide the specific byte layouts you're requesting based on the content provided.

While the webpage describes what these packets do—for example, it mentions that port 50001 type `0x03` handles "Channels On Air" and type `0x0b` handles "Absolute Position"—it does not include the detailed byte-level specifications, field offsets, or data structure definitions for any of these packet types.

The page is a table of contents and overview document that "details what has been learned so far about the protocol," but the actual technical specifications would be found in the linked detailed pages (like `beats.html`, `mixer_integration.html`, `media.html`, etc.), which are not included in the content you provided.

Additionally, there is no mention of packet type `0x40` on port 50002 in this document—only types `05`, `06`, `0a`, `19`, `1a`, `29`, and `34` are listed for that port.

To get the exact byte layouts you need, you would need to consult the full documentation pages on the DJ Link Ecosystem Analysis site.


 I'm checking beat-link's channel documentation and the packet types listed in packets.html for port 50002 — the standard types are 05, 06, 0a, 19, 1a, 29, and 34. The 0x40 type that's appearing from the CDJ-3000 isn't in that documented list, which suggests it's a newer undocumented packet type specific to the CDJ-3000 or recent firmware updates.

Now I need to pull the getAudibleChannels method from beat-link's source to understand how it parses channels on air, and then look at the PrecisePosition class to see the exact field offsets for the position data.


packets.html confirms a useful detail: port 50002 documents types `05,06,0a,19,1a,29,34` — **`0x40` is undocumented**, so the CDJ-3000's `0x40` is a newer-firmware packet not in the analysis. Let me get the exact channels-on-air and precise-position layouts from beat-link.


Bash

cd /tmp && echo "=== getAudibleChannels ==="; sed -n '/getAudibleChannels/,/^    }/p' bf.java | head -45

Read channels-on-air parsing


=== getAudibleChannels ===
                                            final Set<Integer> audibleChannels = getAudibleChannels(packet);
                                            deliverOnAirUpdate(audibleChannels);
                                            if (isFromXdjAz(packet)) {  // Record that we saw an XDJ-AZ channels-on-air packet
                                                lastSeenXdjAzChannelsOnAir.set(System.nanoTime());
                                            }
                                        }
                                        break;

                                    case SYNC_CONTROL:
                                        if (isPacketLongEnough(packet, 0x2c, "sync control command")) {
                                            deliverSyncCommand(packet.getData()[0x2b]);
                                        }
                                        break;

                                    case MASTER_HANDOFF_REQUEST:
                                        if (isPacketLongEnough(packet, 0x28, "tempo master handoff request")) {
                                            deliverMasterYieldCommand(packet.getData()[0x21]);
                                        }
                                        break;

                                    case MASTER_HANDOFF_RESPONSE:
                                        if (isPacketLongEnough(packet, 0x2c, "tempo master handoff response")) {
                                            byte[] data = packet.getData();
                                            deliverMasterYieldResponse(data[0x21], data[0x2b] == 1);
                                        }
                                        break;

                                    case FADER_START_COMMAND:
                                        if (isPacketLongEnough(packet, 0x28, "fader start command")) {
                                            byte[] data = packet.getData();
                                            Set<Integer> playersToStart = new TreeSet<>();
                                            Set<Integer> playersToStop = new TreeSet<>();
                                            for (int channel = 1; channel <= 4; channel++) {
                                                switch (data[0x23 + channel]) {

                                                    case 0:
                                                        playersToStart.add(channel);
                                                        break;

                                                    case 1:
                                                        playersToStop.add(channel);
                                                        break;

                                                    case 2:
                                                        // Leave this player alone
Shell cwd was reset to /Users/noah/conductor/workspaces/deadca7/san-diego


cd /tmp && gh api repos/Deep-Symmetry/beat-link/contents/src/main/java/org/deepsymmetry/beatlink/PrecisePosition.java --jq '.content' 2>/dev/null | base64 -d | grep -nE 'bytesToNumber|playbackPosition|trackLength|pitch|deviceNumber|0x[0-9a-f]+' | head -30

Read PrecisePosition field offsets


20:    private final int trackLength;
25:    private final int playbackPosition;
28:     * The device playback pitch found in the packet.
30:    private final int pitch;
45:        super(packet, "Precise position", 0x3c, 0x21);
46:        trackLength = (int)Util.bytesToNumber(packetBytes, 0x24, 4);
47:        playbackPosition = (int)Util.bytesToNumber(packetBytes, 0x28, 4);
48:        long rawPitch = Util.bytesToNumber(packetBytes, 0x2c, 4);
49:        if (rawPitch > 0x80000000L) {  // This is a negative effective tempo
50:            rawPitch -= 0x100000000L;
52:        pitch = (int)Util.percentageToPitch(rawPitch / 100.0);
53:        bpm = (int)Util.bytesToNumber(packetBytes, 0x38, 4) * 10;
63:        return trackLength;
73:        return playbackPosition;
78:        return pitch;
83:        return (int)Math.round(bpm / Util.pitchToMultiplier(pitch));
88:        return "Precise position: Device " + deviceNumber + ", name: " + deviceName +
89:                ", pitch: " + String.format("%+.2f%%", Util.pitchToPercentage(pitch)) +
103:        return (master != null) && master.getAddress().equals(address) && master.getDeviceNumber() == deviceNumber;
Shell cwd was reset to /Users/noah/conductor/workspaces/deadca7/san-diego


 I'm mapping out the PrecisePosition packet structure with the device number at offset 0x21, then tracking several fields like track length, playback position in milliseconds, pitch as a signed value divided by 100 for percentage, and BPM multiplied by 10.

Now I need to find the actual getAudibleChannels method body since the grep only showed the call site — the channels-on-air parsing varies depending on packet type, with standard packets having four channel flags at bytes 0x24-0x27 and the DJM-V10 variant using six channels at different offsets. Let me search for the full method definition to get the exact offsets.


Let me get the exact channels-on-air parsing (it differs for the DJM-V10's 6-channel packet):


cd /tmp && awk '/private (static )?Set<Integer> getAudibleChannels/,/^    }$/' bf.java | head -50

Read getAudibleChannels method body


    private static Set<Integer> getAudibleChannels(DatagramPacket packet) {
        byte[] data = packet.getData();
        Set<Integer> audibleChannels = new TreeSet<>();
        for (int channel = 1; channel <= 4; channel++) {
            if (data[0x23 + channel] != 0) {
                audibleChannels.add(channel);
            }
        }
        if (packet.getLength() >= 0x35) {
            for (int channel = 5; channel <= 6; channel++) {
                if (data[0x28 + channel] != 0) {
                    audibleChannels.add(channel);
                }
            }
        }
        audibleChannels = Collections.unmodifiableSet(audibleChannels);
        return audibleChannels;
    }
Shell cwd was reset to /Users/noah/conductor/workspaces/deadca7/san-diego


 I'm mapping out the on-air status for all six channels on the DJM-V10: channels 1-4 use bytes 0x24-0x27, while channels 5-6 are at 0x2d and 0x2e since the packet length is 0x35. Now I need to figure out how the device number relates to the channels-on data.

I've got the key packet layouts sorted: the 0x03 packet gives me channel on-air flags, and the 0x0b packet contains device ID at 0x21 plus playback metrics like track length, position, pitch, and BPM. The 0x04 and 0x40 packets are still mysteries, though 0x40 is apparently undocumented anyway.

Now I need to give the user the action checklist for the capture window since time is critical — they should be performing specific actions while the 100-second capture is running to generate meaningful packet data.


I have all the layouts I need. Let me check how much of the capture window is left and whether the virtual CDJ is online.


cd /Users/noah/conductor/workspaces/deadca7/san-diego/link
F=/private/tmp/claude-501/-Users-noah-conductor-workspaces-deadca7-san-diego/5e143c63-1d6b-4233-91af-bb63e39e8beb/tasks/bqe9dmr1n.output
grep -vE '^\{' $F 2>/dev/null | tail -3
wc -l testdata/session.jsonl 2>/dev/null || echo "no file yet"

Check capture progress


online as device 5
    3015 testdata/session.jsonl


 The capture is running with the virtual CDJ online as device 5, and I've already collected over 3000 packets into the session file. I need to give the user the action checklist now so they can execute steps during the remaining time window, but I'm realizing I don't have a clear sense of how much time is left—I should estimate based on the packet rate to figure out the urgency.

At roughly 60 packets per second, I've got about 50 seconds of capture time remaining, which is enough for the user to run through the key protocol exercises. Let me send them a crisp action list: play tracks on different decks, move the crossfader to toggle channels on-air, adjust tempo/pitch, load a new track, and maybe set sync or master mode. I'll note that if they run out of time before finishing, we can just start another capture window.


Virtual CDJ is online as device 5 and packets are streaming (3,015 so far, ~half the window left). I've got the exact layouts ready to decode. **Please run through these now** on the gear while it captures:

1. **Play deck 1** (CDJ-3) — gives a *playing* status + beats + advancing position
2. **Play deck 2** (CDJ-4) — two decks at once
3. **Crossfade / raise-lower channel faders** so each deck goes **on-air then off-air** — drives the mixer's channels-on-air
4. **Nudge the tempo/pitch fader** on a deck — pitch/BPM change
5. **Load a different track** on a deck — new track ID in status

No rush to hit them all; whatever you do shows up. If the window closes first, I'll just start another.

While it ran I pulled the layouts from beat-link and checked the page you linked:

- **Precise position (0x0b)** — device `0x21`, track length `0x24`, **playback position (ms) `0x28`**, pitch `0x2c` (signed /100), BPM `0x38`. This is the smooth playhead, ~30/s — ideal for a visualizer.
- **Channels-on-air (0x03)** — channels 1–4 at `0x24–0x27`, and **5–6 at `0x2d–0x2e`** for the DJM-V10's longer (0x35) packet; non-zero = audible.
- **`0x40` on 50002 is undocumented** on the analysis site (it lists only `05/06/0a/19/1a/29/34` for that port), so the CDJ-3000's `0x40` is a newer-firmware packet we'll have to reverse from the bytes ourselves.

I'll decode precise-position and channels-on-air (and dig into the unidentified `0x04`/`0x40`) as soon as the capture finishes.


<task-notification>
<task-id>bqe9dmr1n</task-id>
<tool-use-id>toolu_018F6QCntWcW38XmSh5npRin</tool-use-id>
<output-file>/private/tmp/claude-501/-Users-noah-conductor-workspaces-deadca7-san-diego/5e143c63-1d6b-4233-91af-bb63e39e8beb/tasks/bqe9dmr1n.output</output-file>
<status>completed</status>
<summary>Background command "Rich 100s dynamic session capture" completed (exit code 0)</summary>
</task-notification>


cd /Users/noah/conductor/workspaces/deadca7/san-diego/link
python3 -c '
import json,collections
c=collections.OrderedDict()
for l in open("testdata/session.jsonl"):
    p=json.loads(l); d=bytes.fromhex(p["data"])
    if d[:10]!=bytes.fromhex("5173707431576d4a4f4c"): continue
    key=(p["port"],d[0x0a],p["src"].split(":")[0])
    c[key]=c.get(key,0)+1
print("port  type  src              count")
for (port,typ,src),n in sorted(c.items()):
    print("%5d 0x%02x %-15s %5d"%(port,typ,src,n))
'

Inventory the session capture


port  type  src              count
50000 0x00 192.168.0.160       3
50000 0x02 192.168.0.160       3
50000 0x04 192.168.0.160       1
50000 0x05 192.168.0.193       1
50000 0x06 192.168.0.160      65
50000 0x06 192.168.0.192      46
50000 0x06 192.168.0.193      49
50000 0x06 192.168.0.194      46
50000 0x0a 192.168.0.160       3
50001 0x03 192.168.0.193     329
50001 0x04 192.168.0.193       6
50001 0x0b 192.168.0.192    3228
50002 0x0a 192.168.0.192     581
50002 0x0a 192.168.0.194     484
50002 0x40 192.168.0.192      48
50002 0x40 192.168.0.194      48


 Looking at the session capture, both CDJs are present and sending status messages on port 50002, but only the .192 device transmitted precise position data. This suggests either .194 wasn't actively playing or handles position reporting differently, even though both should be CDJ-3000 units.

The absence of beat messages (0x28) is telling — neither deck was actually playing during this capture, which explains why we're seeing position updates and status frames but no playback activity. The mixer is reporting channels on-air and we have good data on device states, so I should decode the precise position from .192 to extract playback position, track length, pitch, and BPM information, then examine the mixer's channels-on-air messages to see if any toggling occurred, and finally check the status frames from both decks for playing flags, track IDs, and master/on-air states.

Writing analysis script...


Rich capture — **two decks** (`.192`, `.194`) both sending status, the mixer's channels-on-air (329), and **3,228 precise-position packets**. Notably **no `0x28` beats**, which means the decks weren't actually playing. Let me decode the dynamics to see exactly what we got (positions, on-air changes, track IDs, play state).


cd /Users/noah/conductor/workspaces/deadca7/san-diego/link
python3 -c '
import json
def u(b,o,n): return int.from_bytes(b[o:o+n],"big")
rows=[json.loads(l) for l in open("testdata/session.jsonl")]
def pkts(port,typ,src=None):
    out=[]
    for p in rows:
        d=bytes.fromhex(p["data"])
        if p["port"]==port and len(d)>0x0a and d[0x0a]==typ and (src is None or p["src"].startswith(src)):
            out.append(d)
    return out

print("=== precise position 0x0b from .192 (device, pos ms, len, pitch, bpm) ===")
pp=pkts(50001,0x0b,"192.168.0.192")
def pos(d):
    pitch=u(d,0x2c,4);
    if pitch>0x80000000: pitch-=0x100000000
    return (d[0x21],u(d,0x24,4),u(d,0x28,4),pitch,u(d,0x38,4)*10)
samp=[pos(d) for d in pp]
print("  first:",samp[0]); print("  mid:  ",samp[len(samp)//2]); print("  last: ",samp[-1])
print("  pos range: %d .. %d ms"%(min(s[2] for s in samp),max(s[2] for s in samp)))
print("  pitch set:",sorted(set(s[3] for s in samp))[:8])

print("=== channels-on-air 0x03 from mixer (distinct audible sets over time) ===")
ca=pkts(50001,0x03,"192.168.0.193")
prev=None
for d in ca:
    ch=[c for c in range(1,5) if d[0x23+c]!=0]
    if len(d)>=0x35: ch+=[c for c in range(5,7) if d[0x28+c]!=0]
    if ch!=prev: print("  on-air:",ch); prev=ch

print("=== status 0x0a distinct (device, trackID, playing, onair, master, bpm) ===")
seen=set()
for d in pkts(50002,0x0a):
    f=d[0x89]
    key=(d[0x21],u(d,0x2c,4),bool(f&0x40),bool(f&0x08),bool(f&0x20),u(d,0x92,2)/100)
    if key not in seen: seen.add(key); print("  ",key)
'

Decode session dynamics


=== precise position 0x0b from .192 (device, pos ms, len, pitch, bpm) ===
  first: (3, 286, 29565, 25, 10030)
  mid:   (3, 286, 29565, 25, 10030)
  last:  (3, 286, 29565, 25, 10030)
  pos range: 29565 .. 29565 ms
  pitch set: [25]
=== channels-on-air 0x03 from mixer (distinct audible sets over time) ===
  on-air: [3]
=== status 0x0a distinct (device, trackID, playing, onair, master, bpm) ===
   (4, 0, False, False, False, 655.35)
   (3, 1, False, True, True, 100.0)


 I'm examining the binary structure of device 3's data, checking that my offset mapping is correct—the track length at 0x24 is 286, position in milliseconds at 0x28 is 29565, pitch at 0x2c is 25, and BPM calculation at 0x38 multiplied by 10 gives 10030, which all aligns with my expected field layout.

The position value is static (29565 throughout), meaning the deck was paused at about 29.5 seconds into the track rather than actively playing. The track length of 286 is in seconds, not milliseconds, which clarifies the unit convention. The pitch value of 25 appears to be the raw 4-byte value at that offset, likely representing a very small pitch adjustment near zero since the fader was at the bottom.

Only device 3 remained on-air throughout the entire capture, so I'm tracking a single deck's state.

Device 4 shows no track loaded (trackID 0, invalid BPM of 655.35 from 0xffff/100), while device 3 has track 1 loaded, is on-air and set as master, but remains paused. The user didn't actually play anything, toggle on-air, adjust tempo, or load tracks during this window—the state was completely static. This means I have valid packet structures to decode from the real 0x0b and 0x03 packets, but I haven't captured the dynamic scenarios like position advancing, on-air toggling, or beat packets. I can build decoders and tests with these static values now, but I'll still need a capture where the user actually plays or manipulates the decks to validate the dynamic behavior.

For the DecodePosition packet, I need to figure out the units for the trackLength field. Position is 29565 (milliseconds, so 29.5 seconds), and trackLength is 286. If that were milliseconds it'd be absurdly short, so it must be in seconds—286 seconds is about 4:46, which is plausible for a track. The position of 29.5 seconds fits well within a 286-second track, so I'll store trackLength as seconds.

Now I'm defining the decoders for DecodeOnAir (0x03) to identify which channels are audible on the mixer, and DecodePosition (0x0b) to capture device, position in milliseconds, track length in seconds, pitch, and BPM. I'll add these alongside the existing beat-port packet decoders in packet.go to keep all the wire protocol handlers together.

Defining the packet type constants...

Now I'm working through the Position struct fields — I need to decide how to represent pitch since the precise position packet uses a different format than the beat packet, and I'm considering whether to store it as a raw signed integer or convert it to a percentage value for consistency. I'm settling on a minimal Position struct with Device, Length, Playback, and BPM as the core fields, skipping pitch to avoid misrepresentation. For BPM, I'll compute it as the effective tempo by dividing the raw u32 value at offset 0x38 by 10, giving me 100.3 for the test data. Now I need to verify the OnAir packet structure—specifically whether offset 0x21 contains the mixer device number like it does in other beat-port packets.

Rather than use the massive session.jsonl file, I'll create focused fixtures with just a few 0x0b and 0x03 packets, then write the decoders and tests to confirm my offset interpretations are correct.


Decoded — and this tells us the deck state precisely: **deck 3 has track 1 loaded, on-air, master, but paused at 29.565s** (position is static, no `0x28` beats). Deck 4 is empty (track 0, BPM 655.35 = no-track sentinel). So we captured the *structures* for precise-position and channels-on-air, but not the *dynamics* (playing, on-air toggle, tempo change) — the decks were never played.

Key field facts confirmed against real packets:
- **Precise position**: device 3, length `0x24`=286 (seconds), playback `0x28`=29565 (ms), BPM `0x38`→100.3 — position static = paused.
- **Channels-on-air**: only channel 3 audible the whole time (never toggled).

Let me build the decoders for both and lock them in with trimmed fixtures + tests.


Edit

const (
	TypeBeat      Type = 0x28
	TypeKeepAlive Type = 0x06
)

const (
	TypeOnAir     Type = 0x03
	TypeKeepAlive Type = 0x06
	TypePosition  Type = 0x0b
	TypeBeat      Type = 0x28
)


The file /Users/noah/conductor/workspaces/deadca7/san-diego/link/packet.go has been updated successfully. (file state is current in your context — no need to Read it back)

const (
	TypeOnAir     Type = 0x03
	TypeKeepAlive Type = 0x06
	TypePosition  Type = 0x0b
	TypeBeat      Type = 0x28
)

package link

import (
	"bytes"
	"encoding/binary"
	"net"

	"github.com/cockroachdb/errors"
)

// Magic is the ten-byte preamble that begins every Pro DJ Link packet.
var Magic = []byte{0x51, 0x73, 0x70, 0x74, 0x31, 0x57, 0x6d, 0x4a, 0x4f, 0x4c}

// Type is the packet kind, found at the byte following Magic and interpreted
// together with the UDP port the packet arrived on.
type Type byte

const (
	TypeBeat      Type = 0x28
	TypeKeepAlive Type = 0x06
)

// PitchNormal is the raw pitch value representing 100% (normal) playback speed.
const PitchNormal = 0x100000

// ErrNotDJLink is returned when a payload does not begin with Magic.
var ErrNotDJLink = errors.New("not a DJ Link packet")

// KeepAlive is a device announcement broadcast on port 50000.
type KeepAlive struct {
	Device byte
	IP     net.IP
	MAC    net.HardwareAddr
	Name   string
}

// DecodeKeepAlive parses a keep-alive (type 0x06) packet.
func DecodeKeepAlive(b []byte) (KeepAlive, error) {
	if len(b) < 0x30 || !bytes.Equal(b[:len(Magic)], Magic) {
		return KeepAlive{}, ErrNotDJLink
	}
	if t := Type(b[0x0a]); t != TypeKeepAlive {
		return KeepAlive{}, errors.Errorf("want keep-alive 0x06, got 0x%02x", byte(t))
	}

	return KeepAlive{
		Device: b[0x24],
		IP:     net.IP(append([]byte(nil), b[0x2c:0x30]...)),
		MAC:    net.HardwareAddr(append([]byte(nil), b[0x26:0x2c]...)),
		Name:   string(bytes.TrimRight(b[0x0c:0x20], "\x00")),
	}, nil
}

// Beat is a beat packet broadcast on port 50001 by a playing player.
type Beat struct {
	BeatInBar byte
	BPM       float64
	Device    byte
	Name      string
	Pitch     int
}

// DecodeBeat parses a beat (type 0x28) packet. BPM is the track tempo; multiply
// by Pitch/PitchNormal for the effective tempo.
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
}


	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
}

	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
}

// Position is a precise-position packet broadcast on port 50001 by a player,
// giving a smooth playhead at ~30/s. Length is in seconds, Playback in
// milliseconds; BPM is the effective tempo.
type Position struct {
	BPM      float64
	Device   byte
	Length   uint32
	Playback uint32
	Pitch    int32
}

// DecodePosition parses a precise-position (type 0x0b) packet.
func DecodePosition(b []byte) (Position, error) {
	if len(b) < 0x3c || !bytes.Equal(b[:len(Magic)], Magic) {
		return Position{}, ErrNotDJLink
	}
	if t := Type(b[0x0a]); t != TypePosition {
		return Position{}, errors.Errorf("want position 0x0b, got 0x%02x", byte(t))
	}

	return Position{
		BPM:      float64(binary.BigEndian.Uint32(b[0x38:0x3c])) / 10,
		Device:   b[0x21],
		Length:   binary.BigEndian.Uint32(b[0x24:0x28]),
		Playback: binary.BigEndian.Uint32(b[0x28:0x2c]),
		Pitch:    int32(binary.BigEndian.Uint32(b[0x2c:0x30])),
	}, nil
}

// OnAir is a channels-on-air packet broadcast on port 50001 by a mixer, listing
// the player channels that are currently audible.
type OnAir struct {
	Channels []byte
	Device   byte
}

// DecodeOnAir parses a channels-on-air (type 0x03) packet. Channels 1-4 live at
// 0x24-0x27; a DJM-V10 adds channels 5-6 at 0x2d-0x2e in its longer packet.
func DecodeOnAir(b []byte) (OnAir, error) {
	if len(b) < 0x2d || !bytes.Equal(b[:len(Magic)], Magic) {
		return OnAir{}, ErrNotDJLink
	}
	if t := Type(b[0x0a]); t != TypeOnAir {
		return OnAir{}, errors.Errorf("want on-air 0x03, got 0x%02x", byte(t))
	}

	o := OnAir{Device: b[0x21]}
	for c := byte(1); c <= 4; c++ {
		if b[0x23+c] != 0 {
			o.Channels = append(o.Channels, c)
		}
	}
	if len(b) >= 0x35 {
		for c := byte(5); c <= 6; c++ {
			if b[0x28+c] != 0 {
				o.Channels = append(o.Channels, c)
			}
		}
	}
	return o, nil
}