diff --git a/docs/VDJ_DMXDESKTOP_REVERSE_ENGINEERING_PROMPT.md b/docs/VDJ_DMXDESKTOP_REVERSE_ENGINEERING_PROMPT.md new file mode 100644 index 0000000000..378bd8549b --- /dev/null +++ b/docs/VDJ_DMXDESKTOP_REVERSE_ENGINEERING_PROMPT.md @@ -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 ` 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. diff --git a/plugins/os2l/os2lplugin.cpp b/plugins/os2l/os2lplugin.cpp index 6c7373382a..048e279901 100644 --- a/plugins/os2l/os2lplugin.cpp +++ b/plugins/os2l/os2lplugin.cpp @@ -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") { diff --git a/plugins/os2l/os2lplugin.h b/plugins/os2l/os2lplugin.h index 33d32a9cdb..92d7f8d0ba 100644 --- a/plugins/os2l/os2lplugin.h +++ b/plugins/os2l/os2lplugin.h @@ -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); diff --git a/qmlui/CMakeLists.txt b/qmlui/CMakeLists.txt index 6c3a42c02a..feb1bf2813 100644 --- a/qmlui/CMakeLists.txt +++ b/qmlui/CMakeLists.txt @@ -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 diff --git a/qmlui/app.cpp b/qmlui/app.cpp index a1c049f7f7..fa14593a12 100644 --- a/qmlui/app.cpp +++ b/qmlui/app.cpp @@ -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" @@ -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) @@ -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); @@ -210,6 +219,7 @@ void App::startup() qmlRegisterUncreatableType("org.qlcplus.classes", 1, 0, "ShowManager", "Can't create a ShowManager!"); qmlRegisterUncreatableType("org.qlcplus.classes", 1, 0, "NetworkManager", "Can't create a NetworkManager!"); qmlRegisterUncreatableType("org.qlcplus.classes", 1, 0, "SimpleDesk", "Can't create a SimpleDesk!"); + qmlRegisterUncreatableType("org.qlcplus.classes", 1, 0, "VdjBridge", "Use the vdjBridge context property"); // Start up in non-modified state m_doc->resetModified(); @@ -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 diff --git a/qmlui/app.h b/qmlui/app.h index 9cc585a528..a0710c5835 100644 --- a/qmlui/app.h +++ b/qmlui/app.h @@ -47,6 +47,7 @@ class VideoProvider; class FixtureEditor; class FlowConsole; class Tardis; +class VdjBridge; class QMouseEvent; #define SETTINGS_LANGUAGE "ui/language" @@ -246,6 +247,7 @@ protected slots: UiManager *m_uiManager; Tardis *m_tardis; FlowConsole *m_flowConsole; + VdjBridge *m_vdjBridge; /********************************************************************* * Doc diff --git a/qmlui/main.cpp b/qmlui/main.cpp index 8295e0bb38..c63ab5a2a2 100644 --- a/qmlui/main.cpp +++ b/qmlui/main.cpp @@ -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. diff --git a/qmlui/qml/showmanager/ShowManager.qml b/qmlui/qml/showmanager/ShowManager.qml index f172dcede0..6306f3a542 100644 --- a/qmlui/qml/showmanager/ShowManager.qml +++ b/qmlui/qml/showmanager/ShowManager.qml @@ -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") diff --git a/qmlui/test/CMakeLists.txt b/qmlui/test/CMakeLists.txt index 80bf5bde0c..cc735ff01f 100644 --- a/qmlui/test/CMakeLists.txt +++ b/qmlui/test/CMakeLists.txt @@ -1,3 +1,4 @@ project(test) add_subdirectory(palettegenerator) +add_subdirectory(vdjbridge) diff --git a/qmlui/test/vdjbridge/CMakeLists.txt b/qmlui/test/vdjbridge/CMakeLists.txt new file mode 100644 index 0000000000..f917117467 --- /dev/null +++ b/qmlui/test/vdjbridge/CMakeLists.txt @@ -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 +) diff --git a/qmlui/test/vdjbridge/vdjbridge_test.cpp b/qmlui/test/vdjbridge/vdjbridge_test.cpp new file mode 100644 index 0000000000..2049052043 --- /dev/null +++ b/qmlui/test/vdjbridge/vdjbridge_test.cpp @@ -0,0 +1,38 @@ +/* + Q Light Controller Plus - Unit test + vdjbridge_test.cpp +*/ + +#include +#include + +#include "vdjbridge_test.h" +#include "vdjbridge.h" + +void VdjBridge_Test::initialState() +{ + VdjBridge b; + QCOMPARE(b.connected(), false); + QCOMPARE(b.beatCount(), 0); +} + +void VdjBridge_Test::beatTicksCounterAndConnected() +{ + VdjBridge b; + QSignalSpy connectedSpy(&b, &VdjBridge::connectedChanged); + QSignalSpy beatSpy(&b, &VdjBridge::beatReceived); + + b.onBeat(); + + QCOMPARE(b.connected(), true); + QCOMPARE(b.beatCount(), 1); + QCOMPARE(connectedSpy.count(), 1); + QCOMPARE(beatSpy.count(), 1); + + b.onBeat(); + QCOMPARE(b.beatCount(), 2); + // connected stays true — no extra connectedChanged emission + QCOMPARE(connectedSpy.count(), 1); +} + +QTEST_MAIN(VdjBridge_Test) diff --git a/qmlui/test/vdjbridge/vdjbridge_test.h b/qmlui/test/vdjbridge/vdjbridge_test.h new file mode 100644 index 0000000000..0c5ee8e27c --- /dev/null +++ b/qmlui/test/vdjbridge/vdjbridge_test.h @@ -0,0 +1,20 @@ +/* + Q Light Controller Plus - Unit test + vdjbridge_test.h +*/ + +#ifndef VDJBRIDGE_TEST_H +#define VDJBRIDGE_TEST_H + +#include + +class VdjBridge_Test : public QObject +{ + Q_OBJECT + +private slots: + void initialState(); + void beatTicksCounterAndConnected(); +}; + +#endif diff --git a/qmlui/vdjbridge.cpp b/qmlui/vdjbridge.cpp new file mode 100644 index 0000000000..7830a796fe --- /dev/null +++ b/qmlui/vdjbridge.cpp @@ -0,0 +1,86 @@ +/* + Q Light Controller Plus + vdjbridge.cpp + + Copyright (c) Massimo Callegari + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0.txt + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +#include "vdjbridge.h" +#include "qlcioplugin.h" + +#include + +VdjBridge::VdjBridge(QObject *parent) + : QObject(parent) +{ +} + +void VdjBridge::attachOS2LPlugin(QLCIOPlugin *plugin) +{ + if (m_plugin == plugin) + return; + + if (!m_plugin.isNull()) + m_plugin->disconnect(this); + + m_plugin = plugin; + if (m_plugin.isNull()) + { + if (m_connected) + { + m_connected = false; + emit connectedChanged(); + } + return; + } + + // String-based connections so qmlui does not need to link the plugin + // shared library. Signatures must match exactly what the plugin emits. + connect(m_plugin.data(), SIGNAL(beatReceived()), + this, SLOT(onBeat())); + connect(m_plugin.data(), SIGNAL(connectionStatusChanged(quint32,quint32)), + this, SLOT(refreshConnectionStatus())); + + refreshConnectionStatus(); +} + +void VdjBridge::refreshConnectionStatus() +{ + bool now = false; + if (!m_plugin.isNull()) + now = (m_plugin->connectionStatus(0) == QLCIOPlugin::Connected); + + if (now != m_connected) + { + m_connected = now; + emit connectedChanged(); + } +} + +void VdjBridge::onBeat() +{ + ++m_beatCount; + + // The first beat we receive is the most reliable evidence VDJ is actually + // streaming to us. connectionStatusChanged covers the TCP handshake but + // not every transport (e.g. dropped + re-routed Bonjour entries). + if (!m_connected) + { + m_connected = true; + emit connectedChanged(); + } + + emit beatReceived(); +} diff --git a/qmlui/vdjbridge.h b/qmlui/vdjbridge.h new file mode 100644 index 0000000000..25f97d08e2 --- /dev/null +++ b/qmlui/vdjbridge.h @@ -0,0 +1,84 @@ +/* + Q Light Controller Plus + vdjbridge.h + + Copyright (c) Massimo Callegari + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0.txt + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +#ifndef VDJBRIDGE_H +#define VDJBRIDGE_H + +#include +#include + +class QLCIOPlugin; + +/** + * Qt-facing facade for the OS2L plugin's beat tick stream. + * + * Exposes ONLY what stock VirtualDJ verifiably broadcasts via OS2L: + * - A bare `evt:"beat"` message at the beat rate (no payload). + * - TCP connection state from the OS2L plugin. + * + * The bridge does NOT expose BPM, beat position, song info, or any + * other field, because VDJ does not send them. Computing BPM from + * inter-arrival times is possible but is a measurement task that + * belongs elsewhere — not in this passive facade. + * + * Connections from the plugin are made by App using Qt's string-based + * signal/slot syntax so qmlui does not need to link against the plugin .so. + */ +class VdjBridge final : public QObject +{ + Q_OBJECT + + Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged) + Q_PROPERTY(int beatCount READ beatCount NOTIFY beatReceived) + +public: + explicit VdjBridge(QObject *parent = nullptr); + + /** + * Bind to an OS2L plugin instance and start consuming its events. + * The plugin pointer may be null (no OS2L plugin available); the bridge + * stays in a disconnected state. Subsequent calls replace any prior binding. + */ + void attachOS2LPlugin(QLCIOPlugin *plugin); + + bool connected() const { return m_connected; } + int beatCount() const { return m_beatCount; } + +public slots: + /** Connected to OS2LPlugin::beatReceived (string-based). */ + void onBeat(); + + /** Connected to QLCIOPlugin::connectionStatusChanged. + * Re-queries the plugin and updates the connected property. */ + void refreshConnectionStatus(); + +signals: + void connectedChanged(); + void beatReceived(); + +private: + /** Held as a base-class pointer so qmlui does not need to link the + * OS2L plugin's shared library. Becomes null if the plugin is unloaded. */ + QPointer m_plugin; + + bool m_connected = false; + int m_beatCount = 0; +}; + +#endif // VDJBRIDGE_H