Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions docs/VDJ_DMXDESKTOP_REVERSE_ENGINEERING_PROMPT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# VDJ ↔ QLC+ Telemetry Gap-Fill: Research Prompt

This file is a **self-contained prompt** for a future Claude Code session that
runs on your machine (with VirtualDJ and DMXDesktop actually installed and
reachable). Open this repo in Claude Code, then paste the prompt below into a
new session.

---

## Background (what's already done)

This branch (`claude/vdj-song-manager-poc-SqKCZ`) added a VDJ telemetry
pipeline based on the **OS2L** protocol (the open standard QLC+ already speaks
via `plugins/os2l/`). The pipeline is:

```
VirtualDJ ──OS2L (JSON over TCP, port 9996)──► plugins/os2l/os2lplugin.cpp
songReceived(QVariantMap)
beatInfoReceived(bpm,pos,change)
qmlui/vdjbridge.cpp (Qt facade)
ShowManager.slotVdjSongChanged
ShowManager.qml telemetry strip
```

The QML telemetry strip shows: connection status, current BPM, and a
beat-pulse indicator. That's all that is wired today.

## The gap

Stock VirtualDJ + OS2L only broadcasts `evt:"beat"` (with optional
`bpm`, `pos`, `change`). `evt:"btn"` and `evt:"cmd"` exist in the spec
but fire only when the user has written a corresponding VDJ script
calling `os2l_button` / `os2l_cmd`. `evt:"song"` is documented by some
sources but **VirtualDJ does not actually broadcast it** — confirmed
by the project owner. The OS2L plugin's `song`-parsing code path
therefore never runs in practice and we have removed any qmlui-side
consumers that depended on it.

This means OS2L gives us beats and nothing more. It **does not** give
us:

- Anything that identifies the current track (title, artist, file path,
database id).
- The full **beat grid** (an array of beat times) — only individual
`beat` events arrive as they happen, which is fine for live sync but
not for pre-planning cues on a timeline ahead of playback.
- Loops, hot cues, deck-internal markers.

**Plan from the user:** DMXDesktop (the lighting app from VDJ's own ecosystem)
talks to VirtualDJ over a richer, non-OS2L protocol that exposes the file
path and probably more. We want to reverse-engineer that protocol and add a
second backend that feeds the same `VdjBridge` facade — so consumers
(`ShowManager`, the QML telemetry strip, future MCP tools) don't change.

## Prerequisites on your machine

- VirtualDJ Pro 2024+ installed
- DMXDesktop installed and configured to talk to VDJ
- Wireshark, tcpdump, or equivalent
- A few audio tracks loaded into VDJ
- This repo built (`cd build && cmake --build . --target qlcplus-qml -j8`)

---

## ===== Prompt to paste into a new Claude Code session =====

> I need help reverse-engineering the network protocol DMXDesktop uses to talk
> to VirtualDJ, so I can add a second backend to my QLC+ `VdjBridge` that
> fills in the gaps OS2L doesn't cover — specifically the **audio file path**
> of the playing track and, if possible, the **full beat grid**.
>
> Read `docs/VDJ_DMXDESKTOP_REVERSE_ENGINEERING_PROMPT.md` for full background.
> The existing OS2L-based pipeline lives in:
>
> - `plugins/os2l/os2lplugin.{h,cpp}` — emits
> `beatInfoReceived(double,double,bool)`. Do NOT extend this plugin
> with non-OS2L data; OS2L stays strictly conformant.
> - `qmlui/vdjbridge.{h,cpp}` — the Qt facade. Add a second backend here,
> not in the OS2L plugin. The facade currently exposes only beat /
> BPM / connection state; you will add song / path / beatgrid
> properties as the new backend provides them.
> - `qmlui/showmanager.cpp` — does NOT yet contain any auto-create logic.
> That work was removed because it depended on VDJ-broadcasted song
> events that don't actually exist. Once a new backend supplies song
> metadata + file path, the auto-create-show / attach-Audio-function
> logic needs to be (re-)added here.
>
> ### Step 1 — Capture
>
> 1. Identify what DMXDesktop listens on (port / interface). Likely
> candidates: a TCP/UDP port on localhost, a websocket, or a named pipe.
> Start with `lsof -i -P -n | grep -i dmxdesktop` (mac) or
> `netstat -anp | findstr dmxdesktop` (windows).
> 2. Start a Wireshark capture on `lo`/loopback filtered to the DMXDesktop
> process, or use `tcpdump -i lo0 -A -s 0 port <port>` to a pcap file.
> 3. In VDJ: load a track on deck 1, start playing, scrub, change BPM,
> activate a hot cue, set a loop, switch decks, change track. Each
> distinct action becomes a labelled section of your capture.
> 4. Save the pcap as `vdj-dmxdesktop.pcap` somewhere I can read.
>
> ### Step 2 — Decode
>
> Read `vdj-dmxdesktop.pcap` with tshark or scapy. Most likely the wire
> format is one of: JSON-over-TCP (line-delimited), length-prefixed binary
> structs, MessagePack, or a WebSocket text frame stream. For each message
> type observed, produce a struct-style description: name, fields, types,
> typical values, frequency. Pay special attention to:
>
> - A message that arrives once per track load — should contain the file
> path or at least a stable track id we can resolve via VDJ's database
> (`VirtualDJ Database v6.xml` / `database.xml`).
> - A message containing a beat-time array or a CBG (computed beat grid).
> - Any handshake / hello / version-negotiation frames.
>
> Compare each message to the OS2L spec — anything OS2L can already deliver
> should be ignored (we already get it).
>
> ### Step 3 — Specify
>
> Write a markdown spec at `docs/DMXDESKTOP_PROTOCOL.md` containing:
>
> - Transport details (port, framing, handshake).
> - One section per message type with example payloads.
> - A mapping table: which DMXDesktop fields fill which `VdjBridge` slots
> (specifically `path`, `bpm`, `pos`, `elapsed`, `duration`, `name`,
> `artist`, plus any new fields like `beatgrid`, `loops`, `cues`).
> - Open questions (anything not figured out yet).
>
> Stop and show me the spec before writing C++ code — I want to sanity-check
> before we commit to a backend implementation.
>
> ### Step 4 — Implement the second backend
>
> Add a sibling to the OS2L plugin: probably a new `plugins/vdjbridge/`
> plugin that implements `QLCIOPlugin` and emits the same
> `songReceived(QVariantMap)` / `beatInfoReceived(...)` signals, with the
> QVariantMap fully populated (including `path` and, if available, a
> `beatgrid` array). Then in `qmlui/app.cpp` look it up the same way the
> OS2L plugin is looked up and call
> `m_vdjBridge->attachOS2LPlugin(plugin)` — rename the method to
> `attachVdjPlugin` if both can be present at once (priority: richer
> backend wins). Add a unit test that pipes a captured frame through
> the parser and asserts `songReceived` carries the expected map.
>
> ### Step 5 — Beat grid in Song Manager
>
> Once the QVariantMap has a `beatgrid` array (list of beat times in ms),
> extend `qmlui/qml/showmanager/ShowManager.qml` to overlay tick marks at
> those positions on the timeline (in addition to the BPM-derived grid that
> already renders). Add a "snap to beat" toggle.
>
> ### Constraints
>
> - **Do not** change `plugins/os2l/` to carry new fields beyond OS2L. The
> user wants OS2L to stay strictly conformant.
> - Keep everything additive: existing Shows / playback / VC behaviour
> must not change.
> - One commit per step. Build (`cmake --build build --target qlcplus-qml`)
> and run `vdjbridge_test` between commits.

## Notes for future-me

- If DMXDesktop turns out to use proprietary obfuscation that isn't worth
reverse-engineering, the fallback is to install a **VirtualDJ custom
plugin** (VDJ exposes a C++ SDK) that publishes the missing fields over
a side channel of our own design. That's a lot more work than a backend
decoder but is a known-good escape hatch.
5 changes: 4 additions & 1 deletion plugins/os2l/os2lplugin.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -454,10 +454,13 @@ void OS2LPlugin::slotProcessTCPPackets()
}
else if (event == "beat")
{
// "beat" event — BPM synchronization.
// "beat" event — BPM synchronization. Stock VDJ sends a bare
// `{"evt":"beat"}`; the spec allows bpm/pos/change but VDJ
// does not include them, so we do not parse them.
qDebug() << "[OS2L] Beat message received";
diagLog("message", "beat");
emit valueChanged(m_inputUniverse, 0, 8341, 255, "beat");
emit beatReceived();
}
else if (event == "song")
{
Expand Down
8 changes: 8 additions & 0 deletions plugins/os2l/os2lplugin.h
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ class OS2LPlugin final : public QLCIOPlugin
quint32 universe() const;
bool bonjourEnabled() const;

signals:
/** Emitted on every OS2L "beat" event. Carries no payload because
* stock VirtualDJ broadcasts a bare `{"evt":"beat"}` — the optional
* bpm/pos/change fields in the OS2L spec are permitted but VDJ
* does not include them. Consumers that need BPM must derive it
* from inter-arrival times themselves. */
void beatReceived();

protected:
bool enableTCPServer(bool enable);
quint16 getHash(QString channel);
Expand Down
1 change: 1 addition & 0 deletions qmlui/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ set(SRC_FILES
treemodel.cpp treemodel.h
treemodelitem.cpp treemodelitem.h
uimanager.cpp uimanager.h
vdjbridge.cpp vdjbridge.h
videoeditor.cpp videoeditor.h
videoprovider.cpp videoprovider.h
waveformimageprovider.cpp waveformimageprovider.h
Expand Down
22 changes: 22 additions & 0 deletions qmlui/app.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,12 @@
#include "tardis.h"
#include "networkmanager.h"
#include "flowconsole.h"
#include "vdjbridge.h"

#include "qlcfixturedefcache.h"
#include "audioplugincache.h"
#include "ioplugincache.h"
#include "qlcioplugin.h"
#include "rgbscriptscache.h"
#include "qlcconfig.h"
#include "qlcfile.h"
Expand Down Expand Up @@ -90,6 +93,7 @@ App::App()
, m_networkManager(nullptr)
, m_uiManager(nullptr)
, m_flowConsole(nullptr)
, m_vdjBridge(nullptr)
, m_doc(nullptr)
, m_docLoaded(false)
, m_printItem(nullptr)
Expand Down Expand Up @@ -199,6 +203,11 @@ void App::startup()

m_flowConsole = new FlowConsole(this, m_doc);

// VDJ telemetry bridge. Plugins are not yet loaded here (initDoc runs
// later from startup()); the actual OS2L plugin lookup happens there.
m_vdjBridge = new VdjBridge(this);
rootContext()->setContextProperty("vdjBridge", m_vdjBridge);

m_contextManager->registerContext(m_virtualConsole);
m_contextManager->registerContext(m_flowConsole);
m_contextManager->registerContext(m_simpleDesk);
Expand All @@ -210,6 +219,7 @@ void App::startup()
qmlRegisterUncreatableType<ShowManager>("org.qlcplus.classes", 1, 0, "ShowManager", "Can't create a ShowManager!");
qmlRegisterUncreatableType<NetworkManager>("org.qlcplus.classes", 1, 0, "NetworkManager", "Can't create a NetworkManager!");
qmlRegisterUncreatableType<SimpleDesk>("org.qlcplus.classes", 1, 0, "SimpleDesk", "Can't create a SimpleDesk!");
qmlRegisterUncreatableType<VdjBridge>("org.qlcplus.classes", 1, 0, "VdjBridge", "Use the vdjBridge context property");

// Start up in non-modified state
m_doc->resetModified();
Expand Down Expand Up @@ -580,6 +590,18 @@ void App::initDoc()
m_doc->ioPluginCache()->load(IOPluginCache::systemPluginDirectory());
#endif

// Attach the VDJ telemetry bridge to the OS2L plugin if it loaded.
// Plugin may be absent (build without OS2L, or load failure) — bridge
// simply stays in disconnected state in that case.
if (m_vdjBridge != nullptr)
{
QLCIOPlugin *os2l = m_doc->ioPluginCache()->plugin("OS2L");
if (os2l != nullptr)
m_vdjBridge->attachOS2LPlugin(os2l);
else
qDebug() << "[VdjBridge] OS2L plugin not available";
}

/* Load audio decoder plugins
* This doesn't use a AudioPluginCache::systemPluginDirectory() cause
* otherwise the qlcconfig.h creation should have been moved into the
Expand Down
2 changes: 2 additions & 0 deletions qmlui/app.h
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class VideoProvider;
class FixtureEditor;
class FlowConsole;
class Tardis;
class VdjBridge;
class QMouseEvent;

#define SETTINGS_LANGUAGE "ui/language"
Expand Down Expand Up @@ -246,6 +247,7 @@ protected slots:
UiManager *m_uiManager;
Tardis *m_tardis;
FlowConsole *m_flowConsole;
VdjBridge *m_vdjBridge;

/*********************************************************************
* Doc
Expand Down
2 changes: 2 additions & 0 deletions qmlui/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ int main(int argc, char *argv[])
QApplication app(argc, argv);

/* Force dark mode so the macOS title bar and window chrome match the dark UI */
#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
QGuiApplication::styleHints()->setColorScheme(Qt::ColorScheme::Dark);
#endif

// Since Qt6, the default rendering backend is Rhi.
// QLC+ doesn't support it yet so OpenGL have to be forced.
Expand Down
64 changes: 64 additions & 0 deletions qmlui/qml/showmanager/ShowManager.qml
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,70 @@ Rectangle
Layout.fillWidth: true
}

// VDJ telemetry strip — populated by VdjBridge from the OS2L plugin.
// Visible at all times so the user can see "not connected"; lights up
// and shows current song / BPM / beat when VDJ is streaming.
Rectangle
{
id: vdjStrip
Layout.preferredHeight: parent.height - 8
Layout.preferredWidth: vdjRow.implicitWidth + 16
Layout.alignment: Qt.AlignVCenter
color: "#1a1a1a"
border.color: vdjBridge.connected ? "#2ecc71" : "#444"
border.width: 1
radius: 4

// Pulses on each beat — provides a quick visual sanity check
// that beat events are actually arriving from VDJ.
Rectangle
{
id: beatPulse
anchors.left: parent.left
anchors.leftMargin: 6
anchors.verticalCenter: parent.verticalCenter
width: 10
height: 10
radius: 5
color: vdjBridge.connected ? "#2ecc71" : "#555"
opacity: 0.4
Behavior on opacity { NumberAnimation { duration: 120 } }
}

Connections
{
target: vdjBridge
function onBeatReceived()
{
beatPulse.opacity = 1.0
beatPulseFade.restart()
}
}

Timer
{
id: beatPulseFade
interval: 90
onTriggered: beatPulse.opacity = 0.4
}

RowLayout
{
id: vdjRow
anchors.fill: parent
anchors.leftMargin: 22
anchors.rightMargin: 6
spacing: 10

RobotoText
{
label: vdjBridge.connected ? qsTr("VDJ") : qsTr("VDJ —")
fontSize: UISettings.textSizeDefault - 2
labelColor: vdjBridge.connected ? "#2ecc71" : "#888"
}
}
}

RobotoText
{
label: qsTr("Markers")
Expand Down
1 change: 1 addition & 0 deletions qmlui/test/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
project(test)

add_subdirectory(palettegenerator)
add_subdirectory(vdjbridge)
19 changes: 19 additions & 0 deletions qmlui/test/vdjbridge/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
set(module_name "vdjbridge_test")

add_executable(${module_name} WIN32
${module_name}.cpp ${module_name}.h
../../vdjbridge.cpp ../../vdjbridge.h
)

target_include_directories(${module_name} PRIVATE
../../../engine/src
../../../plugins/interfaces
../..
)

target_link_libraries(${module_name} PRIVATE
Qt${QT_MAJOR_VERSION}::Core
Qt${QT_MAJOR_VERSION}::Gui
Qt${QT_MAJOR_VERSION}::Test
qlcplusengine
)
Loading
Loading