Skip to content

cdj-emulator: virtual playing CDJs (status + presence, no audio) - #61

Draft
vynulldev wants to merge 26 commits into
mainfrom
cdj-emulator
Draft

vynulldev wants to merge 26 commits into
mainfrom
cdj-emulator

Conversation

@vynulldev

Copy link
Copy Markdown
Owner

What & why

Phase 1 of the CDJ emulator from issue #41 (playing-simulation mode): Vynull can act as one or more virtual CDJs that play a track, advancing a real beat grid at a set pitch, broadcasting a live playing status, announcing themselves on the link, and controllable at runtime.

Beyond #41 this is a software test rig. Our monitor, stream overlay, MPRIS bridge, and play history could until now only be exercised against the physical rig; a virtual playing deck drives all of them on a loopback or isolated network with no hardware in the room.

This phase is status-only and off by default. There is no audio and no per-beat (0x28) packet yet. Nothing changes in normal operation unless you pass the new --simulate flag. Full scope, decisions, and phasing live in docs/design/cdj-emulator.md.

How it works

proto.MarshalStatusCDJPlaying overlays the dynamic fields of a playing CDJ status (play state, loaded track, pitch, BPM, beat-in-track, beat-in-bar, sync/master/on-air flags) onto the existing known-good idle template. It sets exactly the fields ParseCDJStatus reads back, so a built status round-trips through our own parser.

device.SimDeck is the engine: a clock over a beat grid that advances a playhead at pitch and reports a CDJPlayState. No audio, just time over the grid: load/play/pause/cue/seek/pitch, beat-in-track and beat-in-bar derivation, end-of-track, effective BPM. The time source is injectable for tests.

device.SimManager holds up to 4 of those decks keyed by Pro DJ Link player number (1-4). --simulate runs Vynull as a rekordbox metadata source (device 17), with the virtual decks as CDJ players that play tracks linked from it: the status broadcast loop emits one dynamic status per deck, each under its own player number, and each deck also announces itself with a 0x06 keep-alive on port 50000. --simulate starts with a single deck as Player 1. Modelling Vynull as the rekordbox source (rather than the decks as the source) is what lets metadata clients resolve each deck's track: they fetch it from the source device's dbserver, which is us.

Local visibility and presence

listenStatus drops packets from our own IP, so without help the emulator would be invisible to this instance's own UI. In --simulate the broadcast loop parses the very bytes it sends and feeds them to the monitor, so the virtual decks appear in /api/players, the TUI, the now-playing card, MPRIS, and history on the same host. Each deck's track resolves against our own database (its source device number is ours, so externalSource stays false), so titles and artwork are correct.

For other software and real gear, each deck sends a 0x06 keep-alive per 1.5s tick, which is what beat-link's DeviceFinder and real players use to list devices. All decks share our MAC/IP with distinct device numbers, the same one-IP-many-numbers pattern rekordbox uses for its claimed slots, so no per-deck claim handshake is needed. In --simulate only the deck numbers are announced, not our own source device number, so trackers list exactly the virtual players and not a phantom entry for our controller identity (which carries no status).

Control surface

Manager-level, under /api/sim/:

Route Method Body Effect
status GET - list every deck + the deck cap
add POST {number?} add a deck (0/omitted = lowest free)
remove POST {number} remove a deck
renumber POST {number, to} change a deck's player number

Per-deck, under /api/sim/{number}/: status (GET), load ({track_id|file_path}), play/pause/cue/eject, seek ({position_ms}), pitch ({pitch_pct}), onair ({on}), master ({on}). On-air and master are off by default (the safe profile); a now-playing overlay follows the deck marked on-air.

CLI (vynull sim ..., --json for pipelines):

vynull sim status                     table of every virtual deck
vynull sim add [number]               add a deck
vynull sim remove <number>
vynull sim renumber <old> <new>
vynull sim <load|play|pause|cue|seek|pitch|onair|master|eject> [args] [--deck N]

Transport subcommands target one deck via --deck N, defaulting to the lowest-numbered deck, so the common one-deck case still reads vynull sim play.

Web UI: an EMULATOR tab that reveals itself only when the server runs with --simulate (it probes /api/sim/status, which 503s otherwise). Each deck is a card with a transport, seek bar, pitch slider, a live readout (state, title, base to effective BPM, bar-beat dots), a player-number dropdown to renumber, a remove button, and ON-AIR / MASTER toggles; an ADD CDJ button and a shared "load into Player N" library search sit alongside. The bar-beat dots animate smoothly between polls (see below); it otherwise polls at 2 Hz while visible, and the decks already feed the monitor, so the now-playing card and overlay move in step.

The bar-beat dots are animated in a requestAnimationFrame loop rather than redrawn on the 2 Hz poll, so they step in time with the music instead of stuttering against the poll clock. The loop extrapolates each playing deck's beat from its effective BPM and is seeded with the exact sub-beat phase the deck reports (beat_fraction, 0..1 through the current beat, computed from the grid), so the next flip lands at the true wall-clock moment; every poll re-anchors it, so it self-corrects without freezing or flashing. This is still an interpolation of the status, not a hardware-exact lock, which arrives with the 0x28 beat packet in the next phase.

Try it (isolated network recommended)

vynull --interface eth0 --simulate --web
vynull sim add 2
vynull sim load "some track" --deck 1
vynull sim play --deck 1

The web overlay, the monitor, and a tool like beat-link-trigger then follow the virtual players.

Hardware testing

  • Tested on: N/A for normal operation: with --simulate off (the default) there is no deck-facing change, and the status/announce paths are byte-identical to before. The --simulate path has been validated against prolink-tools (an independent prolink-connect client) on a LAN that also had a real DJM-A9 present: it lists Vynull as a rekordbox source plus the virtual CDJ players, resolves each deck's track metadata (title/artist/genre/key/BPM) from our dbserver, and its Now-Playing overlay follows whichever deck is toggled on-air. Not yet put in front of real CDJ players.

The design validates the test-rig way first. The dynamic status sets exactly the fields ParseCDJStatus reads, verified by a round-trip test, so it drives our own monitor/overlay/MPRIS/history without hardware. Byte-exact fidelity of the status against a real CDJ-2000NXS2, and the 0x28 beat packet (so trackers lock beat phase, not just presence), are deferred to the next phase (gated on a real-deck capture). Until then --simulate ships behind a safe profile: not tempo master, not on-air, documented as isolated/test-rig first. A careful live-rig smoke is the next phase, not this PR.

Scope and tests

~2200 lines across proto (dynamic status), device (SimDeck + SimManager + broadcast/keep-alive wiring), api (the /api/sim routes), cli.go, the web UI, and the design note. Covered by: proto status round-trip + pitch tests; SimDeck engine tests (advance-at-pitch, pause-freeze, end, seek-from-end, downbeat offset, eject, sub-beat fraction); SimManager tests (add/remove/renumber, auto-number, full/collision, injected clock); and httptest coverage of the API (per-deck flow + add/remove/renumber/full/collision + error paths). go build/vet/test ./... pass; the web page JS parses clean under node --check.

Checklist

  • go build ./..., go vet ./..., and go test ./... pass
  • gofmt -l . is clean
  • New source files carry an SPDX header (GPL-3.0-or-later)
  • Tested on real hardware (deck + firmware noted above), or this change doesn't affect deck behaviour (default path unchanged; the --simulate path is status-only, round-trip-tested, and gated to isolated use pending the next-phase hardware smoke)
  • I agree my contribution is licensed under the project's GPLv3

@vynulldev vynulldev mentioned this pull request Oct 10, 2026
2 tasks done
@vynulldev vynulldev added the enhancement New feature or request label Oct 10, 2026
Scope the playing-simulation feature (issue #41): a virtual CDJ that
advances a track's beat grid at pitch and broadcasts live CDJ status +
per-beat packets, controllable at runtime. Doubles as a software test rig
for the monitor/overlay/MPRIS/history, which today only work against the
physical rig.

Grounds it in the current code (we parse every 0x0a field, so emitting is
the inverse; MarshalStatusCDJ is static-idle today; no 0x28 beat emitter;
no deck engine), lays out Phase 1 (status/beat emulation, no audio) vs
Phase 2 (audio + master tempo), the one real RE task (the 0x28 beat
packet), the load-bearing validation (byte-diff vs a real CDJ, test-rig
first), the risks (track-end-load-bug territory; confusing live gear;
device-number collision), and the open decisions.
…core)

proto.MarshalStatusCDJPlaying overlays the dynamic fields of a playing CDJ
status (play state, loaded track, pitch, BPM, beat-in-track, beat-in-bar,
sync/master/on-air flags) onto the known-good idle template built by
MarshalStatusCDJ. It sets exactly the fields ParseCDJStatus reads back, so a
built status round-trips through our own parser and can drive our own
monitor/overlay/MPRIS/history. The NXS2 mirror copies of pitch/BPM we do not
parse are left at template values; byte-exact fidelity against a real CDJ is
a Phase-0 (capture + diff) task.

device.SimDeck is the engine: a clock over a beat grid that advances a
playhead at pitch and reports a proto.CDJPlayState. load/play/pause/cue/seek/
setpitch, beat-in-track and beat-in-bar derived from the grid + downbeat,
end-of-track handling, effective BPM. The time source is injectable; the
clock, pitch scaling, pause-freeze, end clamp, and downbeat offset are unit
tested with no hardware.

Also factor proto.PlayStateName out of the CDJStatus method so the deck can
name states. Decisions locked in the design note: status-only first,
isolated/test-rig first, control via API+CLI behind --simulate.

No wiring into the live broadcast loop yet (next step), so nothing emits on
the network; this is pure, tested machinery.
…late)

Add a --simulate flag (implies --mode cdj) that attaches a device.SimDeck to
the VirtualDevice. In CDJ mode the status broadcast loop then emits a dynamic
playing status built from the deck snapshot (with an incrementing sequence
counter) in place of the static idle status; with no track loaded the deck
reports NoTrack, so behaviour is unchanged until a track is loaded.

The safe profile (not tempo master, not on-air) is the default, per the
isolated test-rig decision. No control surface yet: the next step adds
/api/sim and the 'vynull sim' CLI to load/play/pause/cue/seek/pitch the deck.

Nothing new emits on a live network until --simulate is passed.
Drive the virtual playing deck at runtime:

  /api/sim/status  GET  the deck's state (SimDeckStatus JSON)
  /api/sim/load    POST {track_id|file_path} -> pull the beat grid from
                   analysis and cue the track
  /api/sim/play|pause|cue|eject  POST  transport
  /api/sim/seek    POST {position_ms}
  /api/sim/pitch   POST {pitch_pct}

Every call returns the current status. 503 when the server was not started
with --simulate; 404 for an unknown action or an unanalyzed track.

'vynull sim <status|load|play|pause|cue|seek|pitch|eject>' is the matching
CLI client over that API, with --json for pipelines; seek takes ms or m:ss,
pitch takes a percent. Factor the one-track resolver (ID or unique query)
out of 'vynull load' so 'sim load' reuses it.

Covered by an httptest end-to-end flow (load->play->pitch->pause->seek) plus
the error paths; no device claim needed. Full suite green.
listenStatus drops packets from our own source IP, so a --simulate deck
broadcasts on the wire but is invisible to this instance's own UI. Parse the
status bytes we just built and feed them to the monitor so the virtual deck
shows up locally: /api/players, the TUI, the now-playing card (it resolves
from our own DB, and selectNowPlaying only needs playing + a title), MPRIS,
and history. Parsing the very bytes we broadcast keeps the local view
identical to what a peer sees on the wire. Only active under --simulate.

This closes the single-host test-rig loop: load/play a track via 'vynull sim'
and watch our own overlay and monitor follow it.
Add an EMULATOR tab to the web UI over the /api/sim endpoints. It probes
/api/sim/status and reveals itself only when the server runs with --simulate
(the endpoint 503s otherwise), so the tab is invisible in normal operation.

The panel is a virtual CDJ face: a transport (play/pause/cue/eject with the
right buttons enabled per state), a seek bar (drag to scrub, released value
posts /api/sim/seek), a pitch slider with a reset, and a live readout showing
state, the track title (resolved from the library), base to effective BPM,
pitch, and the bar-beat dots. A search box filters the library (or takes a
bare track ID + Enter) and a click loads the track. It polls the deck at 2 Hz
while visible so the readout follows playback; the deck already feeds the
monitor, so the now-playing card and overlay move in step.

No new server code: pure client over the existing API. Page JS parses clean
(node --check); go build/vet/test green.
/api/sim/load called Analysis.Get, which only returns an in-memory or
already-path-mapped result. After a server restart the in-memory map and the
track->path map are empty, so Get returned nil for a track analyzed in an
earlier session and the load 404'd with 'no analysis for track' even though a
valid .gob cache (or the file itself) was right there.

Switch to the server's getOrAnalyze helper (as the waveform/analysis
endpoints already do): it resolves the file path from the library/PDB,
SetPaths it so the on-disk cache loads, and only analyzes synchronously as a
last resort. A long-library track now loads instantly from cache; an
un-analyzed one is analyzed on the spot.
SimDeck.Eject did '*d = SimDeck{...}' while holding d.mu, which replaced the
locked mutex with a fresh zero-value one; the deferred d.mu.Unlock then
unlocked a never-locked mutex and panicked with 'sync: unlock of unlocked
mutex', taking down the server.

Reset the track/grid/transport/playhead fields in place instead, leaving the
mutex (and pitch + the sync/master/on-air flags) intact. Add a test that
ejects a playing deck, confirms it clears, ejects again, and reloads, which
reproduces the panic on the old code.
Replace the single SimDeck on VirtualDevice with a SimManager holding up to 4
decks keyed by Pro DJ Link player number (1-4). The broadcast loop now emits
one dynamic status per deck under its own number and feeds each into the
monitor, so several virtual CDJs appear as distinct players. --simulate starts
with one deck as Player 1.

SimManager: Add (0 = lowest free number), Remove, Renumber (keeps the loaded
track + transport), Get, Len, Snapshots, Statuses; all numbers validated to
1-4 and unique, capped at 4.

API routing splits into manager-level and per-deck:
  GET  /api/sim/status            list every deck + the cap
  POST /api/sim/add               {number?}
  POST /api/sim/remove            {number}
  POST /api/sim/renumber          {number, to}
  /api/sim/{n}/{load|play|pause|cue|seek|pitch|eject|status}

A removed deck stops broadcasting and ages out of the monitor within 5s. Each
deck's track still resolves against our own DB (TrackDevice = our source
number, so externalSource stays false). Covered by SimManager unit tests and
reworked API tests (per-deck flow + add/remove/renumber/full/collision).

Web UI (single-deck panel) still to update for the multi-deck layout.
Rework the EMULATOR tab for the SimManager. A toolbar shows the deck count and
an ADD CDJ button (disabled at the cap of 4); each virtual deck renders as a
card with a player-number dropdown (renumber to any free 1-4 slot), a remove
button, and the same transport/seek/pitch/readout as before. A shared loader
at the bottom searches the library (or takes a track ID) and loads the picked
track into the deck chosen in an 'into Player N' selector.

Cards rebuild only when the set of player numbers changes (add/remove/
renumber); otherwise values update in place each 500ms poll, with per-deck
seek/pitch drag guards so a drag is not fought by the poll. All actions go
through /api/sim and refresh from a fresh list, so the view stays consistent.
Pure client; page JS parses clean (node --check).
Rework the sim subcommand for the SimManager routes. New manager subcommands:

  vynull sim status            table of every virtual deck (--json for scripts)
  vynull sim add [number]      add a deck (omit number = lowest free)
  vynull sim remove <number>
  vynull sim renumber <old> <new>

Transport subcommands (load/play/pause/cue/seek/pitch/eject) target one deck
via --deck N, defaulting to the lowest-numbered deck when omitted, so the
common one-deck case still reads 'vynull sim play'. --deck is stripped before
the shared flag parser (which would reject it). load reuses resolveOneTrack
(ID or unique query); seek takes ms or m:ss; pitch takes a percent.

Smoke-tested arg handling (routing, defaults, usage + bad-input errors).
Each virtual deck now announces itself with a 0x06 keep-alive on port 50000
every 1.5s (alongside our own device's), so device trackers list the virtual
CDJs as players and associate their 0x0a status. beat-link's DeviceFinder (and
real gear) track devices from these keep-alives; without them a status-only
emulator stayed invisible to those tools.

All decks share our MAC/IP with distinct device numbers, the same
one-IP-many-numbers pattern rekordbox uses for its claimed slots, so no
per-deck claim handshake is needed. Add SimManager.Numbers() for the loop to
enumerate player numbers without snapshotting. Only active under --simulate;
our own number is announced once.
The EMULATOR beat dots updated only on the 500ms status poll, so against a
sub-500ms beat period they jumped and visibly skipped. Extrapolate each
playing deck's current beat from its effective BPM in a requestAnimationFrame
loop, re-anchoring to the authoritative beat_in_bar on every poll so it
self-corrects. The beat now ticks in time with the music.

Split the beat dots into their own .sim-beat span (separate from the BPM/pitch
text) so the animator can update them without rebuilding the readout. The loop
runs only while the EMULATOR tab is visible.
The smoothed beat dots still flashed through a beat every few seconds: the
server's beat_in_bar reflects the deck position at sample time, but the client
pinned that value to the arrival timestamp. When a sample landed just before a
beat boundary, the current beat was held an extra fraction and the next poll
jumped forward, flashing one beat quickly.

Expose the exact sub-beat phase from the deck (beat_fraction, 0..1, computed
from the grid in SimDeck.Status) and seed the animator's crossing count with
it: crossings = floor(frac + elapsedBeats). The next flip now happens at the
true wall-clock moment, so the dots neither freeze nor jump; re-anchoring each
poll stays continuous. Fraction covered by a unit test.
In --simulate the keep-alive loop announced BOTH our own device number (the
cdj-mode source identity, default 3) and each virtual deck. Trackers like
prolink-tools and beat-link list anything that sends a 0x06 keep-alive, so our
source showed up as a phantom CDJ with no track or status alongside the real
virtual decks.

Route the startup and periodic keep-alives through simPresence: in --simulate
it announces only the virtual deck numbers, never our own source number;
otherwise behaviour is unchanged (own 0x06 in both modes; the rekordbox
startup 0x02 burst is untouched). The device still binds its services on its
own number; it just no longer advertises a presence that has no deck behind
it. The one-shot claim sequence uses claim packets (0x00/0x02), not 0x06, so
it never registered our number as a tracked device.
Add a per-request debug log of the message type and its first argument, which
for menu/metadata requests encodes player, slot, and track type. Makes it easy
to see exactly what a client (a real CDJ, or a tool like prolink-tools) asks
for when diagnosing metadata resolution. Debug level so normal sessions stay
quiet; run with --log-level debug to watch.
…racks

prolink-tools showed our virtual CDJ's play state but left its overlay blank:
it fetches a loaded track's metadata from the device named as the track's
source, and our decks named device 3 (our cdj-mode identity) as the source
while we no longer announced it, so there was nothing to query (confirmed from
logs: zero dbserver/NFS fetch attempts).

Model the real rekordbox-link topology instead: --simulate now runs in
rekordbox mode (device 17, a discoverable metadata source), and the virtual
CDJ decks play tracks linked from it (TrackDevice = 17, rekordbox slot). A
metadata client then queries device 17's dbserver (us) for each deck's track;
our own monitor still resolves locally since TrackDevice == our SelfDevice.

- config: --simulate implies rekordbox mode (was cdj).
- statusBroadcastLoop: a dedicated simBroadcastLoop emits per-deck 0x0a
  regardless of base mode, without waiting on a CDJ link; the base rekordbox
  identity broadcasts no player status of its own.
- keep-alives: announce our rekordbox base (not a phantom now, it is a
  rekordbox source) plus each virtual CDJ deck (announceSimDecks).
- sim load: source slot is rekordbox, not USB, to match the source device.

Full suite green.
…erge)

A CDJ status packet carries the player number at BOTH 0x21 and 0x24.
MarshalStatusCDJ only set 0x24 and left the idle template's 0x01 at 0x21 (the
template was captured from player 1). beat-link and prolink-tools key a status
update by 0x21, so every virtual deck's status was attributed to player 1 and
their track/play-state merged in those tools. Our own monitor reads 0x24, so
it was unaffected and the bug stayed hidden.

Set buf[0x21] = deviceNumber in MarshalStatusCDJPlaying (the --simulate path
only; the hardware-validated idle status is untouched). Tests assert both
0x21 and 0x24 carry the deck number for players 1-4.
Now-playing overlays (prolink-tools, and real rigs) pick the live deck by its
on-air flag, so with the safe profile (on-air off) their overlay stays empty
even though device-status shows full metadata. Expose control of it:

- SimDeck.SetOnAir / SetMaster; SimDeckStatus gains on_air/master/sync.
- POST /api/sim/{n}/onair {on} and /api/sim/{n}/master {on}.
- 'vynull sim onair|master [on|off] [--deck N]'; status line + list show flags.
- EMULATOR web cards get ON-AIR / MASTER toggles (on-air lit red, master
  accent), reflecting and setting the deck's flags.

Still off by default (safe profile); the user opts a deck on-air to drive a
now-playing overlay. Full suite green; page JS parses clean.
The dbserver reported art ID 0 for any track whose embedded cover had not been
extracted yet (extraction was lazy, triggered only by the web /api/artwork
endpoint), so a metadata client like prolink-tools requested artwork 0 and got
nothing — the now-playing overlay showed no cover for those tracks.

Factor the web endpoint's lazy extraction into Server.EnsureArtwork(trackID)
and share it with the dbserver via a hook:

- Metadata (0x2002) reports the track ID as the artwork handle when it has no
  art ID yet, so the client actually requests art (0 made it skip).
- The artwork handler (0x2003/0x2103), after the existing cache/ArtID lookups,
  calls EnsureArtwork to extract the embedded cover on demand and retries; the
  result still passes the oversize-downscale guard. Extraction is deferred to
  the art request (not metadata), keeping the metadata response fast.

Helps real CDJs too: a lazily-analyzed track now serves its cover without a
prior web-UI visit. Full suite green.
The Library page's LOAD-to-player menu already lists the virtual decks (they
appear in /api/players via the monitor feed), but loadTrack always posted to
/api/load, whose 0x19 command can't reach a deck that isn't a real peer. Route
a target that is a virtual deck to /api/sim/{n}/load instead (looked up from
/api/sim/status), so you can send a library track straight onto a virtual CDJ.
Real-CDJ loads are unchanged. The play-interrupt confirm still applies, since
virtual decks are in currentPlayers too.
Each virtual deck can now advertise its own model name, so a CDJ-3000 and
an XDJ-RX3 can run side by side. The name is the model string other devices
and clients display; blank falls back to the host device's name. It lives on
SimDeck (persisting across load/eject, like a physical device's) and flows
into both the per-deck keep-alive and status packets.

A + ADD DEVICE modal (player number + model name) replaces the instant add,
and sets the name at creation so the deck advertises the right model from its
very first announce. This matters because clients latch a device's identity
when they first see it, so naming it after it is already on the network is too
late. The per-card model field and `vynull sim name <model>` edit a live deck.

Control surfaces: POST /api/sim/{n}/identity {name}; /api/sim/add also takes a
name; `vynull sim name`; the modal and per-card field in the web panel. The
toolbar label and add button are device-generic.

Virtual devices stay CDJs on the wire: a track-playing deck can't meaningfully
present as a mixer or rekordbox (it emits a CDJ status packet), so there is no
device-type control for now.

Tests cover the model name default, set, persistence across load/eject, and
snapshot propagation.
Seed per-model emulation with a built-in table of known players. A
DeviceProfile carries the identity a client reads off the network, and the
table (device/profiles.go) lists CDJ-3000, CDJ-2000NXS2/nexus/2000, and
XDJ-XZ/RX3/1000MK2/AZ, keyed to match link-spec's per-model goldens so a
profile can later be validated against its <model>-status golden.

GET /api/sim/profiles serves the table, and the web panel offers the models
as autocomplete suggestions (a datalist on the ADD DEVICE modal and the
per-card model-name field); any free-text name still works.

A profile carries only Name for now. Firmware (status offset 0x7c, currently
the fixed "1.85") and capability fields are deliberately left empty until they
can be filled from captures and the <model>-status goldens and plumbed into
the keep-alive/status, rather than asserting versions we have not verified.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant