From 4dd474b851cd264536d5f79f59f0b5d1895921b8 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Thu, 16 Jul 2026 23:55:50 -0400 Subject: [PATCH 01/11] fix(allenheath): open the ACE session with the real seed frame Connecting to a dLive sat in Connecting forever with nothing logged. The handshake sent E0 00 04 02 03 as a "session seed", but that is the console's half of the port exchange, not the client's: the client sends E0 00 04 01 03 E7 and waits for the console to answer with its own port before anything else is accepted. Subscribes were being fired at a console still waiting on that exchange, so it never replied, and nothing timed out because the TCP connect had already stopped the connect timer. - Send the correct seed, carrying the local port of a UDP socket bound for the purpose, and start the subscribe chain only when the reply lands. - Keep-alive is E0 00 01 03 E7 over UDP every 1000ms, not the seed over TCP every 5000ms; the console drops a session silent for 1.5s. - Add an RX watchdog and a handshake timeout so a stall reports instead of hanging. - Write our source id at [5..6] of SET frames; 00 00 addressed nothing. - TcpTransport: carry the socket error enum (Qt renders an unmapped errno as a bare "Unknown error"), and abort a busy socket before reconnecting, which connectToHost() rejects outright. Frames recovered from theatremix.exe. test_allenheath_session drives a real DLiveProtocol against a stub MixRack that answers only a correct seed; it fails against the old handshake. --- .../allenheath/AllenHeathTcpProtocol.cpp | 243 ++++++++++++++---- .../allenheath/AllenHeathTcpProtocol.h | 67 ++++- src/protocol/transport/TcpTransport.cpp | 10 +- tests/CMakeLists.txt | 25 ++ tests/test_allenheath_session.cpp | 223 ++++++++++++++++ 5 files changed, 508 insertions(+), 60 deletions(-) create mode 100644 tests/test_allenheath_session.cpp diff --git a/src/protocol/allenheath/AllenHeathTcpProtocol.cpp b/src/protocol/allenheath/AllenHeathTcpProtocol.cpp index 4cca7a7..795a574 100644 --- a/src/protocol/allenheath/AllenHeathTcpProtocol.cpp +++ b/src/protocol/allenheath/AllenHeathTcpProtocol.cpp @@ -10,10 +10,14 @@ const QByteArray AllenHeathTcpProtocol::kInputMixer = QByteArrayLiteral("Input M const QByteArray AllenHeathTcpProtocol::kDcaLevelsAndMutes = QByteArrayLiteral("DCA Levels and Mutes"); const QByteArray AllenHeathTcpProtocol::kSceneManager = QByteArrayLiteral("StageBox Scene Manager"); +const QByteArray AllenHeathTcpProtocol::kMeteringSources = QByteArrayLiteral("Metering Sources"); namespace { constexpr char ACE_F0 = '\xf0'; constexpr char ACE_F7 = '\xf7'; + +// the console's half of the port exchange; the client's seed carries 01 here +const QByteArray kSessionReply = QByteArrayLiteral("\xe0\x00\x04\x02\x03"); } // namespace AllenHeathTcpProtocol::AllenHeathTcpProtocol(const MixerCapabilities& caps, QObject* parent) @@ -36,6 +40,15 @@ AllenHeathTcpProtocol::AllenHeathTcpProtocol(const MixerCapabilities& caps, QObj QObject::connect(&m_keepAliveTimer, &QTimer::timeout, this, &AllenHeathTcpProtocol::onKeepAliveTimeout); + QObject::connect(&m_rxWatchdogTimer, &QTimer::timeout, this, + &AllenHeathTcpProtocol::onRxWatchdogTimeout); + + m_handshakeTimer.setSingleShot(true); + QObject::connect(&m_handshakeTimer, &QTimer::timeout, this, + &AllenHeathTcpProtocol::onHandshakeTimeout); + + QObject::connect(&m_udpSocket, &QUdpSocket::readyRead, this, + &AllenHeathTcpProtocol::onUdpDataReceived); } AllenHeathTcpProtocol::~AllenHeathTcpProtocol() { disconnect(); } @@ -86,27 +99,15 @@ QByteArray AllenHeathTcpProtocol::encodeDb(double dB) const { } QList AllenHeathTcpProtocol::subscribeObjects() const { - // control-relevant subset of the ACE handshake (identity gate + the three - // objects whose handles the control path addresses). The full reference - // handshake also subscribes name/colour/metering objects used only for RX. - return {QByteArrayLiteral("DR Box Identification"), QByteArrayLiteral("Surface Identification"), - kInputMixer, kDcaLevelsAndMutes, kSceneManager}; -} - -double AllenHeathTcpProtocol::dbFromLevel(double level) { - // OpenMix stores fader positions normalized 0..1; the console wants dB. Map - // with 0 dB at 0.75 travel and +10 dB at the top, -inf at the bottom. - level = std::clamp(level, 0.0, 1.0); - if (level <= 0.0) { - return -96.0; // below the encoder's -95 floor -> 0x0000 - } - if (level >= 1.0) { - return 10.0; - } - if (level < 0.75) { - return -60.0 + (level / 0.75) * 60.0; // -60 .. 0 dB - } - return (level - 0.75) / 0.25 * 10.0; // 0 .. +10 dB + // subset of the reference's chain: the identity gate, the three objects the + // control path addresses, and the metering subscribe that starts the UDP meter + // stream. The reference also subscribes name/colour objects we do not read. + return {QByteArrayLiteral("DR Box Identification"), + QByteArrayLiteral("Surface Identification"), + kInputMixer, + kDcaLevelsAndMutes, + kSceneManager, + kMeteringSources}; } QByteArray AllenHeathTcpProtocol::buildSubscribe(const QByteArray& objectName) { @@ -119,19 +120,37 @@ QByteArray AllenHeathTcpProtocol::buildSubscribe(const QByteArray& objectName) { return f; } +QByteArray AllenHeathTcpProtocol::buildSessionSeed(quint16 udpPort) { + QByteArray f = QByteArray::fromHex("e000040103"); + f.append(static_cast((udpPort >> 8) & 0xFF)); + f.append(static_cast(udpPort & 0xFF)); + f.append('\xe7'); + return f; +} + +QByteArray AllenHeathTcpProtocol::buildKeepAlive() { return QByteArray::fromHex("e0000103e7"); } + +quint16 AllenHeathTcpProtocol::parseSessionReply(const QByteArray& frame) { + if (frame.size() < 8 || !frame.startsWith(kSessionReply)) { + return 0; + } + return static_cast((static_cast(frame.at(5)) << 8) | + static_cast(frame.at(6))); +} + QByteArray AllenHeathTcpProtocol::buildChannelLevel(const QByteArray& handle, int channel, double dB) const { if (handle.isEmpty() || channel < 1) { return {}; } - // F0 00 00 00 00 00 02 F7 + // F0 00 00 00 02 F7 QByteArray f; f.append(ACE_F0); f.append('\0'); f.append('\0'); f.append(handle); - f.append('\0'); - f.append('\0'); + f.append(static_cast((SOURCE_ID >> 8) & 0xFF)); + f.append(static_cast(SOURCE_ID & 0xFF)); f.append(static_cast(channelLevelOp())); f.append(static_cast((channel - 1) & 0xFF)); f.append('\0'); @@ -146,14 +165,14 @@ QByteArray AllenHeathTcpProtocol::buildChannelMute(const QByteArray& handle, int if (handle.isEmpty() || channel < 1) { return {}; } - // F0 00 00 00 00 <(ch-1)+plane> 00 01 F7 + // F0 00 00 <(ch-1)+plane> 00 01 F7 QByteArray f; f.append(ACE_F0); f.append('\0'); f.append('\0'); f.append(handle); - f.append('\0'); - f.append('\0'); + f.append(static_cast((SOURCE_ID >> 8) & 0xFF)); + f.append(static_cast(SOURCE_ID & 0xFF)); f.append(static_cast(channelMuteOp())); f.append(static_cast(((channel - 1) + channelMutePlane()) & 0xFF)); f.append('\0'); @@ -167,14 +186,14 @@ QByteArray AllenHeathTcpProtocol::buildDcaMute(const QByteArray& handle, int dca if (handle.isEmpty() || dca < 1) { return {}; } - // F0 00 00 00 00 10 <(dca-1)+plane> 00 01 F7 + // F0 00 00 10 <(dca-1)+plane> 00 01 F7 QByteArray f; f.append(ACE_F0); f.append('\0'); f.append('\0'); f.append(handle); - f.append('\0'); - f.append('\0'); + f.append(static_cast((SOURCE_ID >> 8) & 0xFF)); + f.append(static_cast(SOURCE_ID & 0xFF)); f.append('\x10'); f.append(static_cast(((dca - 1) + dcaMutePlane()) & 0xFF)); f.append('\0'); @@ -189,14 +208,14 @@ QByteArray AllenHeathTcpProtocol::buildChannelLevelSpill(const QByteArray& bankH if (bankHandle.isEmpty() || channel < 1) { return {}; } - // F0 00 00 00 00 12 <((ch-1)&7)+0x80> 00 02 F7 + // F0 00 00 12 <((ch-1)&7)+0x80> 00 02 F7 QByteArray f; f.append(ACE_F0); f.append('\0'); f.append('\0'); f.append(bankHandle); - f.append('\0'); - f.append('\0'); + f.append(static_cast((SOURCE_ID >> 8) & 0xFF)); + f.append(static_cast(SOURCE_ID & 0xFF)); f.append('\x12'); f.append(static_cast((((channel - 1) & 0x07) + 0x80) & 0xFF)); f.append('\0'); @@ -211,14 +230,14 @@ QByteArray AllenHeathTcpProtocol::buildChannelMuteSpill(const QByteArray& bankHa if (bankHandle.isEmpty() || channel < 1) { return {}; } - // F0 00 00 00 00 12 <((ch-1)&7)+0x98> 00 01 F7 + // F0 00 00 12 <((ch-1)&7)+0x98> 00 01 F7 QByteArray f; f.append(ACE_F0); f.append('\0'); f.append('\0'); f.append(bankHandle); - f.append('\0'); - f.append('\0'); + f.append(static_cast((SOURCE_ID >> 8) & 0xFF)); + f.append(static_cast(SOURCE_ID & 0xFF)); f.append('\x12'); f.append(static_cast((((channel - 1) & 0x07) + 0x98) & 0xFF)); f.append('\0'); @@ -232,15 +251,15 @@ QByteArray AllenHeathTcpProtocol::buildSceneRecall(const QByteArray& handle, int if (handle.isEmpty() || sceneNumber < 1) { return {}; } - // F0 00 00 00 00 10 00 00 02 <(scene-1):2 BE> F7 + // F0 00 00 10 00 00 02 <(scene-1):2 BE> F7 const int s = sceneNumber - 1; QByteArray f; f.append(ACE_F0); f.append('\0'); f.append('\0'); f.append(handle); - f.append('\0'); - f.append('\0'); + f.append(static_cast((SOURCE_ID >> 8) & 0xFF)); + f.append(static_cast(SOURCE_ID & 0xFF)); f.append('\x10'); f.append('\0'); f.append('\0'); @@ -265,17 +284,32 @@ bool AllenHeathTcpProtocol::connect(const QString& host, int port) { setConnectionState(ConnectionState::Connecting); setStatus(QString("Connecting to %1:%2...").arg(host).arg(port)); + // the seed carries our UDP local port, so bind before connecting + if (m_udpSocket.state() != QAbstractSocket::BoundState && + !m_udpSocket.bind(QHostAddress::Any, 0, + QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint)) { + const QString error = + QString("Failed to bind metering socket: %1").arg(m_udpSocket.errorString()); + setStatus(error); + setConnectionState(ConnectionState::Disconnected); + emit connectionError(error); + return false; + } + return m_transport.connect(host, port); } void AllenHeathTcpProtocol::disconnect() { m_keepAliveTimer.stop(); + m_rxWatchdogTimer.stop(); + m_handshakeTimer.stop(); // ACE teardown: E0 00 03 "BYE" E7 if (m_transport.isConnected()) { m_transport.send(QByteArray::fromHex("e00003425945e7")); } m_transport.disconnect(); + m_udpSocket.close(); m_parameterCache.clear(); m_receiveBuffer.clear(); @@ -283,6 +317,8 @@ void AllenHeathTcpProtocol::disconnect() { m_subscribeQueue.clear(); m_subscribeIndex = 0; m_handshakeComplete = false; + m_sessionOpen = false; + m_consoleUdpPort = 0; m_latencyMs = 0; setConnectionState(ConnectionState::Disconnected); @@ -291,25 +327,42 @@ void AllenHeathTcpProtocol::disconnect() { } void AllenHeathTcpProtocol::startHandshake() { - // session seed, then subscribe to the objects whose handles we need to - // address. The console replies to each with a 14-byte frame carrying the - // object's 2-byte handle at offset 11. - m_transport.send(QByteArray::fromHex("e000040203")); - + // the console accepts nothing until the port exchange completes; the subscribe + // chain starts in handleControlFrame() m_handles.clear(); m_subscribeQueue = subscribeObjects(); m_subscribeIndex = 0; m_handshakeComplete = false; - sendNextSubscribe(); + m_sessionOpen = false; + m_consoleUdpPort = 0; + + m_lastRxTimer.start(); + m_handshakeTimer.start(HANDSHAKE_TIMEOUT); + + m_transport.send(buildSessionSeed(m_udpSocket.localPort())); +} + +void AllenHeathTcpProtocol::failHandshake(const QString& reason) { + m_handshakeTimer.stop(); + m_keepAliveTimer.stop(); + m_rxWatchdogTimer.stop(); + m_transport.disconnect(); + m_udpSocket.close(); + m_handshakeComplete = false; + m_sessionOpen = false; + + setConnectionState(ConnectionState::Disconnected); + setStatus(reason); + emit connectionError(reason); } void AllenHeathTcpProtocol::sendNextSubscribe() { if (m_subscribeIndex >= m_subscribeQueue.size()) { // all handles learned: ready to control m_handshakeComplete = true; + m_handshakeTimer.stop(); setConnectionState(ConnectionState::Connected); setStatus(QString("Connected to %1:%2").arg(m_host).arg(m_port)); - m_keepAliveTimer.start(KEEPALIVE_INTERVAL); emit connected(); return; } @@ -331,8 +384,8 @@ void AllenHeathTcpProtocol::sendParameter(const QString& path, const QVariant& v if (ok && parts[0] == "ch") { if (param == "fader") { - m_transport.send(buildChannelLevel(handleFor(kInputMixer), index, - dbFromLevel(value.toDouble()))); + m_transport.send( + buildChannelLevel(handleFor(kInputMixer), index, value.toDouble())); } else if (param == "mute") { m_transport.send(buildChannelMute(handleFor(kInputMixer), index, value.toBool())); } @@ -382,7 +435,7 @@ void AllenHeathTcpProtocol::recallScene(int sceneNumber) { m_transport.send(buildSceneRecall(handleFor(kSceneManager), sceneNumber)); } -void AllenHeathTcpProtocol::setChannelFader(int channel, double level) { +void AllenHeathTcpProtocol::setChannelFaderDb(int channel, double level) { sendParameter(QString("/ch/%1/fader").arg(channel), level); } @@ -396,6 +449,7 @@ void AllenHeathTcpProtocol::refresh() {} void AllenHeathTcpProtocol::onDataReceived(const QByteArray& data) { m_receiveBuffer.append(data); + m_lastRxTimer.restart(); // property frames are length-prefixed: an F0 frame's total length is the // 16-bit big-endian payload length at bytes [9..10] plus 0x0C of header/tail. @@ -420,13 +474,65 @@ void AllenHeathTcpProtocol::onDataReceived(const QByteArray& data) { if (end < 0) { break; } - m_receiveBuffer.remove(0, end + 1); // console control frame; no action + handleControlFrame(m_receiveBuffer.left(end + 1)); + m_receiveBuffer.remove(0, end + 1); } else { m_receiveBuffer.remove(0, 1); // padding / resync } } } +void AllenHeathTcpProtocol::handleControlFrame(const QByteArray& frame) { + const quint16 port = parseSessionReply(frame); + if (port == 0 || m_sessionOpen) { + return; + } + + m_consoleUdpPort = port; + m_sessionOpen = true; + m_keepAliveTimer.start(KEEPALIVE_INTERVAL); + m_rxWatchdogTimer.start(RX_WATCHDOG_INTERVAL); + sendNextSubscribe(); +} + +void AllenHeathTcpProtocol::onUdpDataReceived() { + while (m_udpSocket.hasPendingDatagrams()) { + QByteArray datagram(static_cast(m_udpSocket.pendingDatagramSize()), '\0'); + QHostAddress sender; + m_udpSocket.readDatagram(datagram.data(), datagram.size(), &sender); + + // the socket is dual-stack, so the console's IPv4 address can arrive + // v4-mapped; compare tolerantly or every datagram is discarded as foreign + if (sender.isEqual(QHostAddress(m_host), QHostAddress::TolerantConversion)) { + handleMeterDatagram(datagram); + } + } +} + +void AllenHeathTcpProtocol::handleMeterDatagram(const QByteArray& datagram) { + // meters arrive as one 8-byte record per channel, in channel order: the record + // index is the channel, there is no index field on the wire + if (datagram.size() < METER_MIN_DATAGRAM || areaOf(datagram) != AREA_METERING) { + return; + } + + const QByteArray records = datagram.mid(ACE_HEADER_SIZE); + const int count = qMin(METER_CHANNELS, records.size() / METER_RECORD_SIZE); + for (int i = 0; i < count; ++i) { + const quint8 raw = static_cast(records.at(i * METER_RECORD_SIZE)); + const float level = (raw == 0 || raw >= METER_INVALID) ? 0.0f : raw / 253.0f; + emit channelMeter(i + 1, level); + } +} + +quint16 AllenHeathTcpProtocol::areaOf(const QByteArray& frame) { + if (frame.size() < 5) { + return 0; + } + return static_cast((static_cast(frame.at(3)) << 8) | + static_cast(frame.at(4))); +} + void AllenHeathTcpProtocol::handleAceFrame(const QByteArray& frame) { // A subscribe reply is exactly 14 bytes with the reply marker 00 01 at [1..2] // (console->app; app->console SET frames use 00 00) and carries the learned @@ -441,8 +547,22 @@ void AllenHeathTcpProtocol::handleAceFrame(const QByteArray& frame) { return; } - // other frames are parameter/metering feedback (not needed for the outbound - // control path) + if (areaOf(frame) == AREA_SCENE_CHANGED) { + const int scene = parseSceneChanged(frame); + if (scene > 0) { + emit sceneChanged(scene); + } + } +} + +int AllenHeathTcpProtocol::parseSceneChanged(const QByteArray& frame) { + // payload is the 0-based scene index, big-endian + if (frame.size() < ACE_HEADER_SIZE + 2 || areaOf(frame) != AREA_SCENE_CHANGED) { + return 0; + } + const int index = (static_cast(frame.at(ACE_HEADER_SIZE)) << 8) | + static_cast(frame.at(ACE_HEADER_SIZE + 1)); + return index + 1; } void AllenHeathTcpProtocol::onTransportConnected() { startHandshake(); } @@ -468,9 +588,24 @@ void AllenHeathTcpProtocol::onTransportConnectionLost() { } void AllenHeathTcpProtocol::onKeepAliveTimeout() { - if (m_connectionState == ConnectionState::Connected) { - m_transport.send(QByteArray::fromHex("e000040203")); + if (m_sessionOpen && m_consoleUdpPort != 0) { + m_udpSocket.writeDatagram(buildKeepAlive(), QHostAddress(m_host), m_consoleUdpPort); + } +} + +void AllenHeathTcpProtocol::onRxWatchdogTimeout() { + if (m_sessionOpen && m_lastRxTimer.isValid() && m_lastRxTimer.elapsed() > RX_SILENCE_LIMIT) { + failHandshake( + QString("Console stopped responding (no data for %1ms)").arg(RX_SILENCE_LIMIT)); + } +} + +void AllenHeathTcpProtocol::onHandshakeTimeout() { + if (m_handshakeComplete) { + return; } + failHandshake(m_sessionOpen ? "Console did not complete the subscribe handshake" + : "Console did not answer the session handshake"); } void AllenHeathTcpProtocol::onReconnecting(int attempt, int maxAttempts) { diff --git a/src/protocol/allenheath/AllenHeathTcpProtocol.h b/src/protocol/allenheath/AllenHeathTcpProtocol.h index 6e99689..3c6b864 100644 --- a/src/protocol/allenheath/AllenHeathTcpProtocol.h +++ b/src/protocol/allenheath/AllenHeathTcpProtocol.h @@ -4,9 +4,11 @@ #include "../MixerProtocol.h" #include "../transport/TcpTransport.h" #include +#include #include #include #include +#include namespace OpenMix { @@ -16,6 +18,11 @@ namespace OpenMix { // values are a 2-byte dB encoding. Control covers channel level/mute, DCA mute, // and scene recall (the DCA *fader* plane is receive-only, as in the reference). // Byte layouts recovered from the reference AvantisDriver/DLiveDriver. +// +// A session needs two sockets. The client advertises its UDP local port +// (E0 00 04 01 03 E7), the console answers with its own +// (E0 00 04 02 03 E7), and only then does the subscribe chain start. +// Metering arrives on the UDP socket and the keep-alive goes back out over it. class AllenHeathTcpProtocol : public MixerProtocol { Q_OBJECT @@ -51,7 +58,7 @@ class AllenHeathTcpProtocol : public MixerProtocol { void recallScene(int sceneNumber) override; // semantic setters - void setChannelFader(int channel, double level) override; + void setChannelFaderDb(int channel, double level) override; void setChannelMute(int channel, bool muted) override; // keep-alive @@ -93,6 +100,38 @@ class AllenHeathTcpProtocol : public MixerProtocol { // frame builders (protected for tests). Handle is the 2-byte object handle. static QByteArray buildSubscribe(const QByteArray& objectName); + + static QByteArray buildSessionSeed(quint16 udpPort); + static QByteArray buildKeepAlive(); + + // the console's advertised UDP port; 0 if the frame is not a session reply + static quint16 parseSessionReply(const QByteArray& frame); + + // the functional area a frame belongs to, from [3..4] + static quint16 areaOf(const QByteArray& frame); + + // our source id, written at [5..6] of every frame we originate + static constexpr quint16 SOURCE_ID = 0x0001; + + // inbound frames carry the functional area they belong to at [3..4] + static constexpr quint16 AREA_SUBSCRIBE_REPLY = 0x0001; + static constexpr quint16 AREA_METERING = 0x0006; + static constexpr quint16 AREA_SCENE_CHANGED = 0x000B; + + static constexpr int ACE_HEADER_SIZE = 11; + + // A meter datagram carries one 8-byte record per channel, in channel order; + // only the record's first byte is a level. 0 is -inf and 0xFE/0xFF mark a + // channel the console is not metering, so both read as silence. The byte's dB + // curve is unknown: it is passed through as a monotonic 0..1 position, not a + // calibrated level. + static constexpr int METER_RECORD_SIZE = 8; + static constexpr int METER_CHANNELS = 128; + static constexpr quint8 METER_INVALID = 0xFE; + static constexpr int METER_MIN_DATAGRAM = 200; + + // the 1-based scene the console switched to; 0 if the frame is not a scene change + static int parseSceneChanged(const QByteArray& frame); QByteArray buildChannelLevel(const QByteArray& handle, int channel, double dB) const; QByteArray buildChannelMute(const QByteArray& handle, int channel, bool on) const; QByteArray buildDcaMute(const QByteArray& handle, int dca, bool on) const; @@ -103,13 +142,11 @@ class AllenHeathTcpProtocol : public MixerProtocol { QByteArray buildChannelLevelSpill(const QByteArray& bankHandle, int channel, double dB) const; QByteArray buildChannelMuteSpill(const QByteArray& bankHandle, int channel, bool on) const; - // maps a normalized 0..1 fader position to dB for the encoder - static double dbFromLevel(double level); - // object names whose handles the control path needs static const QByteArray kInputMixer; static const QByteArray kDcaLevelsAndMutes; static const QByteArray kSceneManager; + static const QByteArray kMeteringSources; // subscribing starts the UDP meter stream MixerCapabilities m_capabilities; TcpTransport m_transport; @@ -126,7 +163,10 @@ class AllenHeathTcpProtocol : public MixerProtocol { void onTransportConnectionLost(); void onDataReceived(const QByteArray& data); void onKeepAliveTimeout(); + void onRxWatchdogTimeout(); + void onHandshakeTimeout(); void onReconnecting(int attempt, int maxAttempts); + void onUdpDataReceived(); private: void setStatus(const QString& status); @@ -134,6 +174,9 @@ class AllenHeathTcpProtocol : public MixerProtocol { void startHandshake(); void sendNextSubscribe(); void handleAceFrame(const QByteArray& frame); + void handleControlFrame(const QByteArray& frame); + void handleMeterDatagram(const QByteArray& datagram); + void failHandshake(const QString& reason); QByteArray handleFor(const QByteArray& objectName) const { return m_handles.value(objectName); } QString m_host; @@ -141,8 +184,21 @@ class AllenHeathTcpProtocol : public MixerProtocol { ConnectionState m_connectionState = ConnectionState::Disconnected; QString m_statusMessage; + // metering in, keep-alive out + QUdpSocket m_udpSocket; + quint16 m_consoleUdpPort = 0; + QTimer m_keepAliveTimer; - static constexpr int KEEPALIVE_INTERVAL = 5000; + QTimer m_rxWatchdogTimer; + QTimer m_handshakeTimer; + QElapsedTimer m_lastRxTimer; + + // the console drops a session that goes silent for 1.5 s; any inbound byte + // counts as alive + static constexpr int KEEPALIVE_INTERVAL = 1000; + static constexpr int RX_WATCHDOG_INTERVAL = 500; + static constexpr int RX_SILENCE_LIMIT = 1500; + static constexpr int HANDSHAKE_TIMEOUT = 5000; int m_latencyMs = 0; QByteArray m_receiveBuffer; @@ -151,6 +207,7 @@ class AllenHeathTcpProtocol : public MixerProtocol { QList m_subscribeQueue; int m_subscribeIndex = 0; bool m_handshakeComplete = false; + bool m_sessionOpen = false; }; } // namespace OpenMix diff --git a/src/protocol/transport/TcpTransport.cpp b/src/protocol/transport/TcpTransport.cpp index 4ced4d8..33e7bcd 100644 --- a/src/protocol/transport/TcpTransport.cpp +++ b/src/protocol/transport/TcpTransport.cpp @@ -24,6 +24,12 @@ bool TcpTransport::connect(const QString& host, int port) { disconnect(); } + // connectToHost() is rejected outright unless the socket is unconnected, so a + // socket still connecting or closing from a previous attempt has to be dropped + if (m_socket.state() != QAbstractSocket::UnconnectedState) { + m_socket.abort(); + } + m_host = host; m_port = port; m_reconnectAttempts = 0; @@ -104,7 +110,9 @@ void TcpTransport::onError(QAbstractSocket::SocketError error) { } break; default: - errorMsg = m_socket.errorString(); + // Qt's string for an unmapped errno is a bare "Unknown error"; carry the + // enum so the log can still identify the failure + errorMsg = QString("%1 (socket error %2)").arg(m_socket.errorString()).arg(error); break; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0b744df..27fe334 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -347,9 +347,34 @@ if(WIN32) endif() add_test(NAME AllenHeathParsingTest COMMAND test_allenheath_parsing) +add_executable(test_allenheath_session + test_allenheath_session.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/allenheath/AllenHeathTcpProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/allenheath/DLiveProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/transport/TcpTransport.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/MixerProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/MixerCapabilities.cpp + ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp + ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp +) +target_link_libraries(test_allenheath_session PRIVATE + Qt6::Core + Qt6::Network + Qt6::Test +) +target_include_directories(test_allenheath_session PRIVATE + ${CMAKE_SOURCE_DIR}/src +) +if(WIN32) + target_compile_definitions(test_allenheath_session PRIVATE NOMINMAX) +endif() +add_test(NAME AllenHeathSessionTest COMMAND test_allenheath_session) + add_executable(test_allenheath_midi test_allenheath_midi.cpp ${CMAKE_SOURCE_DIR}/src/protocol/allenheath/AllenHeathMidiProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/allenheath/QuProtocol.cpp ${CMAKE_SOURCE_DIR}/src/protocol/transport/TcpTransport.cpp ${CMAKE_SOURCE_DIR}/src/protocol/MixerProtocol.cpp ${CMAKE_SOURCE_DIR}/src/protocol/MixerCapabilities.cpp diff --git a/tests/test_allenheath_session.cpp b/tests/test_allenheath_session.cpp new file mode 100644 index 0000000..b375fe2 --- /dev/null +++ b/tests/test_allenheath_session.cpp @@ -0,0 +1,223 @@ +#include "protocol/MixerCapabilities.h" +#include "protocol/allenheath/DLiveProtocol.h" +#include +#include +#include +#include + +using namespace OpenMix; + +namespace { + +// Stands in for a dLive MixRack's ACE control socket, replaying the reference's +// side of the session: stay silent until the client advertises its UDP port with +// E0 00 04 01 03 E7, answer with our own port, then hand back a 2-byte +// handle per subscribe (one at a time, as the console does). +class StubMixRack : public QObject { + Q_OBJECT + + public: + explicit StubMixRack(QObject* parent = nullptr) : QObject(parent) { + m_server.listen(QHostAddress::LocalHost, 0); + connect(&m_server, &QTcpServer::newConnection, this, [this]() { + m_client = m_server.nextPendingConnection(); + connect(m_client, &QTcpSocket::readyRead, this, &StubMixRack::onReadyRead); + }); + } + + quint16 port() const { return m_server.serverPort(); } + const QList& subscribesSeen() const { return m_subscribesSeen; } + quint16 clientUdpPort() const { return m_clientUdpPort; } + bool sawSeed() const { return m_sawSeed; } + + // one 8-byte record per channel; only byte 0 is a level + void sendMeters(const QMap& levels) { + QByteArray d = QByteArray::fromHex("f000000006000100020002"); + d.append(QByteArray(METER_CHANNELS * 8, '\0')); + for (auto it = levels.begin(); it != levels.end(); ++it) { + d[11 + (it.key() - 1) * 8] = static_cast(it.value()); + } + QUdpSocket out; + out.writeDatagram(d, QHostAddress::LocalHost, m_clientUdpPort); + } + + void sendSceneChanged(int scene) { + QByteArray f = QByteArray::fromHex("f00000000b000100020002"); + f.append(static_cast(((scene - 1) >> 8) & 0xFF)); + f.append(static_cast((scene - 1) & 0xFF)); + f.append('\xf7'); + m_client->write(f); + } + + static constexpr int METER_CHANNELS = 128; + + // a console that never answers the port exchange + void setMute(bool mute) { m_mute = mute; } + + private slots: + void onReadyRead() { + m_buffer.append(m_client->readAll()); + + while (!m_buffer.isEmpty()) { + const quint8 lead = static_cast(m_buffer.at(0)); + if (lead == 0xE0) { + const int end = m_buffer.indexOf('\xe7', 1); + if (end < 0) { + return; + } + handleControl(m_buffer.left(end + 1)); + m_buffer.remove(0, end + 1); + } else if (lead == 0xF0) { + if (m_buffer.size() < 11) { + return; + } + const int payloadLen = (static_cast(m_buffer.at(9)) << 8) | + static_cast(m_buffer.at(10)); + const int frameLen = payloadLen + 0x0C; + if (m_buffer.size() < frameLen) { + return; + } + handleSubscribe(m_buffer.left(frameLen)); + m_buffer.remove(0, frameLen); + } else { + m_buffer.remove(0, 1); + } + } + } + + private: + void handleControl(const QByteArray& frame) { + // only the client's 01 seed opens a session; a real MixRack ignores anything else + if (!frame.startsWith(QByteArray::fromHex("e000040103")) || frame.size() < 8) { + return; + } + m_sawSeed = true; + m_clientUdpPort = static_cast((static_cast(frame.at(5)) << 8) | + static_cast(frame.at(6))); + if (m_mute) { + return; + } + QByteArray reply = QByteArray::fromHex("e000040203"); + reply.append(static_cast(0x30)); // our UDP port, 0x3039 = 12345 + reply.append(static_cast(0x39)); + reply.append('\xe7'); + m_client->write(reply); + } + + void handleSubscribe(const QByteArray& frame) { + m_subscribesSeen.append(frame); + + // 14-byte handle reply: F0 00 01 <6 bytes> 00 02 F7 + QByteArray reply = QByteArray::fromHex("f00001000000010002"); + reply.append(static_cast(0x00)); + reply.append(static_cast(0x02)); + reply.append(static_cast(0xAB)); + reply.append(static_cast(0xC0 + m_subscribesSeen.size())); + reply.append('\xf7'); + Q_ASSERT(reply.size() == 14); + m_client->write(reply); + } + + QTcpServer m_server; + QTcpSocket* m_client = nullptr; + QByteArray m_buffer; + QList m_subscribesSeen; + quint16 m_clientUdpPort = 0; + bool m_sawSeed = false; + bool m_mute = false; +}; + +} // namespace + +// Drives a real DLiveProtocol against a stub MixRack: the frame-builder tests +// cover bytes, these cover the session. +class TestAllenHeathSession : public QObject { + Q_OBJECT + + private slots: + void connect_completesHandshake() { + StubMixRack rack; + DLiveProtocol p(MixerCapabilities::forConsole(ConsoleType::DLive)); + + QSignalSpy connectedSpy(&p, &MixerProtocol::connected); + QVERIFY(p.connect("127.0.0.1", rack.port())); + QVERIFY(connectedSpy.wait(5000)); + + QVERIFY(p.isConnected()); + QCOMPARE(p.connectionState(), ConnectionState::Connected); + + // the seed must have carried our real, bound UDP port + QVERIFY(rack.sawSeed()); + QVERIFY(rack.clientUdpPort() != 0); + + // every object the control path addresses got subscribed + QCOMPARE(rack.subscribesSeen().size(), 6); + QVERIFY(rack.subscribesSeen().first().contains("DR Box Identification")); + QVERIFY(rack.subscribesSeen().at(2).contains("Input Mixer")); + // without this one the console never starts the UDP meter stream + QVERIFY(rack.subscribesSeen().last().contains("Metering Sources")); + } + + void meters_reachTheChannelMonitor() { + StubMixRack rack; + DLiveProtocol p(MixerCapabilities::forConsole(ConsoleType::DLive)); + + QSignalSpy connectedSpy(&p, &MixerProtocol::connected); + QSignalSpy meterSpy(&p, &MixerProtocol::channelMeter); + QVERIFY(p.connect("127.0.0.1", rack.port())); + QVERIFY(connectedSpy.wait(5000)); + + rack.sendMeters({{1, 0x80}, {2, 0x00}, {7, 0xFF}}); + QTRY_VERIFY_WITH_TIMEOUT(meterSpy.size() >= 128, 3000); + + // channel is the record's position, not a field on the wire + QCOMPARE(meterSpy.at(0).at(0).toInt(), 1); + QCOMPARE(meterSpy.at(0).at(1).toFloat(), 0x80 / 253.0f); + // 0 is -inf and 0xFF marks a channel the console is not metering + QCOMPARE(meterSpy.at(1).at(1).toFloat(), 0.0f); + QCOMPARE(meterSpy.at(6).at(1).toFloat(), 0.0f); + } + + void sceneChangedAtTheConsole_isReported() { + StubMixRack rack; + DLiveProtocol p(MixerCapabilities::forConsole(ConsoleType::DLive)); + + QSignalSpy connectedSpy(&p, &MixerProtocol::connected); + QSignalSpy sceneSpy(&p, &MixerProtocol::sceneChanged); + QVERIFY(p.connect("127.0.0.1", rack.port())); + QVERIFY(connectedSpy.wait(5000)); + + rack.sendSceneChanged(42); + QTRY_COMPARE_WITH_TIMEOUT(sceneSpy.size(), 1, 3000); + QCOMPARE(sceneSpy.at(0).at(0).toInt(), 42); + } + + void subscribesWaitForSeedReply() { + // a console that never answers the port exchange must never see a subscribe + StubMixRack rack; + rack.setMute(true); + DLiveProtocol p(MixerCapabilities::forConsole(ConsoleType::DLive)); + + QVERIFY(p.connect("127.0.0.1", rack.port())); + QTest::qWait(500); + + QVERIFY(rack.sawSeed()); + QVERIFY(rack.subscribesSeen().isEmpty()); + QVERIFY(!p.isConnected()); + } + + void silentConsoleReportsErrorNotHang() { + StubMixRack rack; + rack.setMute(true); + DLiveProtocol p(MixerCapabilities::forConsole(ConsoleType::DLive)); + + QSignalSpy errorSpy(&p, &MixerProtocol::connectionError); + QVERIFY(p.connect("127.0.0.1", rack.port())); + + QVERIFY(errorSpy.wait(8000)); + QCOMPARE(p.connectionState(), ConnectionState::Disconnected); + } +}; + +QTEST_MAIN(TestAllenHeathSession) +#include "test_allenheath_session.moc" From 5b5d5c982e5f05ec897780cffe478f280a2ef8db Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Thu, 16 Jul 2026 23:56:02 -0400 Subject: [PATCH 02/11] fix(discovery): identify ACE consoles with the two-step exchange Auto-scan never listed a dLive. Two causes: - The ACE by-name request answers with a handle, not a name. We parsed that 14-byte handle frame as the identity string, got garbage, matched no model prefix, and dropped the console as Unknown. Read the handle back and parse the answer to that instead. - Probes went out from an ephemeral source port. The reference binds 32323; consoles may answer to it rather than to whatever port we happened to get. Also normalize IPv4-mapped senders (::ffff:a.b.c.d): a dual-stack socket reports them in that form, which then reaches the driver as the literal string to connect to, and the console never answers on it. The read-back framing is a best reading of an ambiguous decompilation and wants confirming against hardware. --- .../discovery/ConsoleDiscoveryService.cpp | 47 +++++++-- .../discovery/ConsoleDiscoveryService.h | 5 +- src/protocol/discovery/OscProbeStrategy.h | 14 +++ .../probes/AllenHeathProbeStrategy.cpp | 36 ++++++- .../probes/AllenHeathProbeStrategy.h | 12 ++- tests/test_allenheath_parsing.cpp | 95 +++++++++++++++---- tests/test_console_discovery.cpp | 65 ++++++++++--- 7 files changed, 228 insertions(+), 46 deletions(-) diff --git a/src/protocol/discovery/ConsoleDiscoveryService.cpp b/src/protocol/discovery/ConsoleDiscoveryService.cpp index cab9065..e208f25 100644 --- a/src/protocol/discovery/ConsoleDiscoveryService.cpp +++ b/src/protocol/discovery/ConsoleDiscoveryService.cpp @@ -29,7 +29,8 @@ void ConsoleDiscoveryService::startScan(int timeoutMs) { m_scanning = true; // IPv4-only socket: dual-stack sockets have platform quirks with IPv4 broadcast - if (!m_socket.bind(QHostAddress::AnyIPv4, 0)) { + if (!m_socket.bind(QHostAddress::AnyIPv4, DISCOVERY_SOURCE_PORT) && + !m_socket.bind(QHostAddress::AnyIPv4, 0)) { m_scanning = false; emit scanError("Failed to bind UDP socket for discovery"); return; @@ -202,6 +203,7 @@ void ConsoleDiscoveryService::launchTcpIdentify(const OscProbeStrategyPtr& strat auto buffer = std::make_shared(); auto done = std::make_shared(false); + auto followUpSent = std::make_shared(false); const int identifyPort = id.port; auto finish = [this, sock, buffer, done, strategy, host, identifyPort]() { @@ -219,12 +221,29 @@ void ConsoleDiscoveryService::launchTcpIdentify(const OscProbeStrategyPtr& strat }; connect(sock, &QTcpSocket::connected, sock, [sock, id]() { sock->write(id.request); }); - connect(sock, &QTcpSocket::readyRead, sock, [sock, buffer, id, finish]() { - buffer->append(sock->readAll()); - if (buffer->size() >= id.minResponseBytes) { - finish(); - } - }); + connect(sock, &QTcpSocket::readyRead, sock, + [sock, buffer, id, finish, followUpSent, strategy, identifyPort]() { + buffer->append(sock->readAll()); + + if (!*followUpSent) { + if (buffer->size() < id.minResponseBytes) { + return; + } + const QByteArray followUp = strategy->identifyFollowUp(identifyPort, *buffer); + if (!followUp.isEmpty()) { + *followUpSent = true; + buffer->clear(); + sock->write(followUp); + return; + } + finish(); + return; + } + + if (buffer->size() >= strategy->identifyFollowUpMinBytes(identifyPort)) { + finish(); + } + }); connect(sock, &QTcpSocket::errorOccurred, sock, [finish](QAbstractSocket::SocketError) { finish(); }); @@ -234,11 +253,21 @@ void ConsoleDiscoveryService::launchTcpIdentify(const OscProbeStrategyPtr& strat sock->connectToHost(host, id.port); } -void ConsoleDiscoveryService::addConsole(const DiscoveredConsole& console) { - if (!console.isValid()) { +void ConsoleDiscoveryService::addConsole(const DiscoveredConsole& in) { + if (!in.isValid()) { return; } + // A dual-stack socket reports an IPv4 sender as ::ffff:a.b.c.d, which then + // reaches the driver as that literal string. Store the plain IPv4 form so + // everything downstream sees an address the console will answer on. + DiscoveredConsole console = in; + bool isV4 = false; + const quint32 v4 = console.address.toIPv4Address(&isV4); + if (isV4) { + console.address = QHostAddress(v4); + } + for (const auto& existing : m_discovered) { if (existing == console) { return; diff --git a/src/protocol/discovery/ConsoleDiscoveryService.h b/src/protocol/discovery/ConsoleDiscoveryService.h index fa3404f..5d87ce0 100644 --- a/src/protocol/discovery/ConsoleDiscoveryService.h +++ b/src/protocol/discovery/ConsoleDiscoveryService.h @@ -35,6 +35,9 @@ class ConsoleDiscoveryService : public QObject { // handles one inbound discovery datagram (exposed for tests) void processDatagram(const QByteArray& data, const QHostAddress& sender, int senderPort); + // consoles may answer to this fixed source port rather than an ephemeral one + static constexpr quint16 DISCOVERY_SOURCE_PORT = 32323; + static QByteArray buildOscMessage(const QString& path); signals: @@ -50,7 +53,7 @@ class ConsoleDiscoveryService : public QObject { private: void sendProbes(); void sendProbesTo(const QHostAddress& target, const QHostAddress& localAddress); - void addConsole(const DiscoveredConsole& console); + void addConsole(const DiscoveredConsole& in); void parseOscMessage(const QByteArray& data, const QHostAddress& sender, int senderPort); QVariant parseOscArgument(const QByteArray& data, int& offset, char type); diff --git a/src/protocol/discovery/OscProbeStrategy.h b/src/protocol/discovery/OscProbeStrategy.h index a03574c..ce13e3e 100644 --- a/src/protocol/discovery/OscProbeStrategy.h +++ b/src/protocol/discovery/OscProbeStrategy.h @@ -85,6 +85,20 @@ class OscProbeStrategy { // the device answers wins. virtual QList tcpIdentifies() const { return {}; } + // second leg of a two-step identify; empty = the first reply is the whole + // answer. The ACE families answer a by-name request with a handle and only + // report the model when that handle is read back. + virtual QByteArray identifyFollowUp(int identifyPort, const QByteArray& firstReply) const { + Q_UNUSED(identifyPort); + Q_UNUSED(firstReply); + return {}; + } + + virtual int identifyFollowUpMinBytes(int identifyPort) const { + Q_UNUSED(identifyPort); + return 0; + } + // parse the response from the handshake sent to identifyPort virtual DiscoveredConsole parseIdentifyResponse(const QByteArray& response, const QHostAddress& sender, diff --git a/src/protocol/discovery/probes/AllenHeathProbeStrategy.cpp b/src/protocol/discovery/probes/AllenHeathProbeStrategy.cpp index d550b63..5809f67 100644 --- a/src/protocol/discovery/probes/AllenHeathProbeStrategy.cpp +++ b/src/protocol/discovery/probes/AllenHeathProbeStrategy.cpp @@ -96,12 +96,46 @@ QList AllenHeathProbeStrategy::tcpIdentifies() const { TcpIdentify ace; ace.port = ACE_IDENTIFY_PORT; // 51321 ace.request = aceRequest(QByteArrayLiteral("DR Box Identification")); - ace.minResponseBytes = 13; // F0 + 10 header + at least one payload byte + F7 + ace.minResponseBytes = 14; // the by-name reply is a fixed 14-byte handle frame ace.timeoutMs = 300; return {sq, ace}; } +QByteArray AllenHeathProbeStrategy::buildAceHandleRead(const QByteArray& handle) { + // F0 F7 + if (handle.size() != 2) { + return {}; + } + QByteArray f; + f.append('\xf0'); + f.append('\0'); + f.append('\x01'); + f.append(handle); + f.append('\0'); + f.append('\x01'); + f.append('\x01'); + f.append('\0'); + f.append('\0'); + f.append('\0'); + f.append('\xf7'); + return f; +} + +QByteArray AllenHeathProbeStrategy::identifyFollowUp(int identifyPort, + const QByteArray& firstReply) const { + // SQ answers the model in one shot; only the ACE families need the read-back + if (identifyPort != ACE_IDENTIFY_PORT || firstReply.size() < 14) { + return {}; + } + return buildAceHandleRead(firstReply.mid(11, 2)); +} + +int AllenHeathProbeStrategy::identifyFollowUpMinBytes(int identifyPort) const { + // header + at least one byte of identity string + F7 + return identifyPort == ACE_IDENTIFY_PORT ? 13 : 0; +} + DiscoveredConsole AllenHeathProbeStrategy::parseIdentifyResponse(const QByteArray& response, const QHostAddress& sender, int identifyPort) const { diff --git a/src/protocol/discovery/probes/AllenHeathProbeStrategy.h b/src/protocol/discovery/probes/AllenHeathProbeStrategy.h index 1797889..2571275 100644 --- a/src/protocol/discovery/probes/AllenHeathProbeStrategy.h +++ b/src/protocol/discovery/probes/AllenHeathProbeStrategy.h @@ -9,9 +9,10 @@ namespace OpenMix { // Stage 2: a follow-up TCP handshake reports the exact model. A&H uses two // distinct identify protocols: // * SQ family - TCP 51326, binary 7F 01 / 7F 02 frame with a model byte. -// * ACE family - TCP 51321, SysEx "DR Box Identification" request whose reply -// string identifies dLive ("TLD..."), Avantis ("Bridge"/"Avantis Solo"), -// or GLD ("GLD..."). +// * ACE family - TCP 51321, SysEx "DR Box Identification" request. The reply is +// a handle, not a name; reading that handle back returns the string that +// identifies dLive ("TLD..."), Avantis ("Bridge"/"Avantis Solo"), or GLD +// ("GLD..."). // Both are attempted; the one the device answers wins. Control ports differ // (SQ/GLD MIDI-over-TCP 51325, Avantis/dLive ACE-TCP 51321). class AllenHeathProbeStrategy : public OscProbeStrategy { @@ -35,6 +36,11 @@ class AllenHeathProbeStrategy : public OscProbeStrategy { QList tcpIdentifies() const override; + QByteArray identifyFollowUp(int identifyPort, const QByteArray& firstReply) const override; + int identifyFollowUpMinBytes(int identifyPort) const override; + + static QByteArray buildAceHandleRead(const QByteArray& handle); + DiscoveredConsole parseIdentifyResponse(const QByteArray& response, const QHostAddress& sender, int identifyPort) const override; diff --git a/tests/test_allenheath_parsing.cpp b/tests/test_allenheath_parsing.cpp index a7efc35..b247cd3 100644 --- a/tests/test_allenheath_parsing.cpp +++ b/tests/test_allenheath_parsing.cpp @@ -11,12 +11,17 @@ namespace { class AvantisProbe : public AvantisProtocol { public: AvantisProbe() : AvantisProtocol(MixerCapabilities::forConsole(ConsoleType::Avantis)) {} + using AllenHeathTcpProtocol::areaOf; using AllenHeathTcpProtocol::buildChannelLevel; using AllenHeathTcpProtocol::buildChannelMute; using AllenHeathTcpProtocol::buildDcaMute; + using AllenHeathTcpProtocol::buildKeepAlive; using AllenHeathTcpProtocol::buildSceneRecall; + using AllenHeathTcpProtocol::buildSessionSeed; using AllenHeathTcpProtocol::buildSubscribe; using AllenHeathTcpProtocol::encodeDb; + using AllenHeathTcpProtocol::parseSceneChanged; + using AllenHeathTcpProtocol::parseSessionReply; }; class DLiveProbe : public DLiveProtocol { @@ -62,33 +67,83 @@ class TestAllenHeathParsing : public QObject { QCOMPARE(p.encodeDb(-6.0), QByteArray::fromHex("7a00")); } + void sessionSeed_advertisesUdpPort() { + // the 01 is what makes it a client seed; sending the console's 02 reply + // back at it opens no session + AvantisProbe p; + QCOMPARE(p.buildSessionSeed(0x1234), QByteArray::fromHex("e0000401031234e7")); + QCOMPARE(p.buildSessionSeed(51321), QByteArray::fromHex("e000040103c879e7")); + QCOMPARE(p.buildSessionSeed(0), QByteArray::fromHex("e0000401030000e7")); + } + + void sessionReply_yieldsConsoleUdpPort() { + AvantisProbe p; + QCOMPARE(p.parseSessionReply(QByteArray::fromHex("e0000402031234e7")), quint16(0x1234)); + // the client's own seed is not a reply, however similar it looks + QCOMPARE(p.parseSessionReply(QByteArray::fromHex("e0000401031234e7")), quint16(0)); + QCOMPARE(p.parseSessionReply(QByteArray::fromHex("e00003425945e7")), quint16(0)); // BYE + QCOMPARE(p.parseSessionReply(QByteArray()), quint16(0)); + QCOMPARE(p.parseSessionReply(QByteArray::fromHex("e00004020312")), quint16(0)); // truncated + } + + void keepAlive_matchesReference() { + AvantisProbe p; + QCOMPARE(p.buildKeepAlive(), QByteArray::fromHex("e0000103e7")); + } + + void sceneChanged_isZeroBasedOnTheWire() { + AvantisProbe p; + // F0 | + QCOMPARE(p.parseSceneChanged(QByteArray::fromHex("f00000000b000100020002" + "0000")), + 1); + QCOMPARE(p.parseSceneChanged(QByteArray::fromHex("f00000000b000100020002" + "0063")), + 100); + QCOMPARE(p.parseSceneChanged(QByteArray::fromHex("f00000000b000100020002" + "0100")), + 257); + // a frame from any other functional area is not a scene change + QCOMPARE(p.parseSceneChanged(QByteArray::fromHex("f000000001000100020002" + "0000")), + 0); + QCOMPARE(p.parseSceneChanged(QByteArray::fromHex("f00000000b")), 0); + } + + void areaOf_readsTheFunctionalArea() { + AvantisProbe p; + QCOMPARE(p.areaOf(QByteArray::fromHex("f000000006000100020002")), quint16(0x0006)); + QCOMPARE(p.areaOf(QByteArray::fromHex("f0000000010001")), quint16(0x0001)); + QCOMPARE(p.areaOf(QByteArray::fromHex("f000")), quint16(0)); + } + void avantis_firmwareGatedOp() { AvantisProbe p; p.setFirmwareRevision(96500); // <= 96884 -> base op 0x25 QCOMPARE(p.buildChannelLevel(H, 1, 0.0), - QByteArray::fromHex("f00000abcd0000250000028000f7")); + QByteArray::fromHex("f00000abcd0001250000028000f7")); p.setFirmwareRevision(97000); // > 96884 -> extended op 0x2B QCOMPARE(p.buildChannelLevel(H, 1, 0.0), - QByteArray::fromHex("f00000abcd00002b0000028000f7")); + QByteArray::fromHex("f00000abcd00012b0000028000f7")); } void dlive_extendedOpForNon1x() { DLiveProbe p; p.setFirmwareVersion("1.90"); // 1.x -> base op 0x30 QCOMPARE(p.buildChannelLevel(H, 1, 0.0), - QByteArray::fromHex("f00000abcd0000300000028000f7")); + QByteArray::fromHex("f00000abcd0001300000028000f7")); p.setFirmwareVersion("2.00"); // non-1.x -> extended op 0x38 QCOMPARE(p.buildChannelLevel(H, 1, 0.0), - QByteArray::fromHex("f00000abcd0000380000028000f7")); + QByteArray::fromHex("f00000abcd0001380000028000f7")); } void dlive_spillFrames() { DLiveProbe p; // op 0x12, level idx ((ch-1)&7)+0x80, mute idx +0x98, bank handle QCOMPARE(p.buildChannelLevelSpill(H, 1, 0.0), - QByteArray::fromHex("f00000abcd0000128000028000f7")); + QByteArray::fromHex("f00000abcd0001128000028000f7")); QCOMPARE(p.buildChannelMuteSpill(H, 1, true), - QByteArray::fromHex("f00000abcd00001298000101f7")); + QByteArray::fromHex("f00000abcd00011298000101f7")); } void subscribe_matchesReferenceBlob() { @@ -101,44 +156,44 @@ class TestAllenHeathParsing : public QObject { AvantisProbe p; // F0 00 00 00 00 25 (ch-1) 00 02 F7 ; ch1 @ 0 dB QCOMPARE(p.buildChannelLevel(H, 1, 0.0), - QByteArray::fromHex("f00000abcd0000250000028000f7")); + QByteArray::fromHex("f00000abcd0001250000028000f7")); // ch2 @ +10 dB -> idx 01, value 8A00 QCOMPARE(p.buildChannelLevel(H, 2, 10.0), - QByteArray::fromHex("f00000abcd0000250100028a00f7")); + QByteArray::fromHex("f00000abcd0001250100028a00f7")); } void avantis_channelMute() { AvantisProbe p; // op 0x26, idx = (ch-1)+0x20, disc 01 ; ch1 mute on / off - QCOMPARE(p.buildChannelMute(H, 1, true), QByteArray::fromHex("f00000abcd00002620000101f7")); + QCOMPARE(p.buildChannelMute(H, 1, true), QByteArray::fromHex("f00000abcd00012620000101f7")); QCOMPARE(p.buildChannelMute(H, 1, false), - QByteArray::fromHex("f00000abcd00002620000100f7")); + QByteArray::fromHex("f00000abcd00012620000100f7")); } void avantis_dcaMute() { AvantisProbe p; // op 0x10, idx = (dca-1)+0x18 ; dca1 on, dca2 -> idx 0x19 - QCOMPARE(p.buildDcaMute(H, 1, true), QByteArray::fromHex("f00000abcd00001018000101f7")); - QCOMPARE(p.buildDcaMute(H, 2, true), QByteArray::fromHex("f00000abcd00001019000101f7")); + QCOMPARE(p.buildDcaMute(H, 1, true), QByteArray::fromHex("f00000abcd00011018000101f7")); + QCOMPARE(p.buildDcaMute(H, 2, true), QByteArray::fromHex("f00000abcd00011019000101f7")); } void sceneRecall_matchesReference() { AvantisProbe p; // F0 00 00 00 00 10 00 00 02 <(scene-1) BE> F7 ; scene 1 -> 0000 - QCOMPARE(p.buildSceneRecall(H, 1), QByteArray::fromHex("f00000abcd0000100000020000f7")); + QCOMPARE(p.buildSceneRecall(H, 1), QByteArray::fromHex("f00000abcd0001100000020000f7")); // scene 256 -> (255) = 00FF - QCOMPARE(p.buildSceneRecall(H, 256), QByteArray::fromHex("f00000abcd00001000000200fff7")); + QCOMPARE(p.buildSceneRecall(H, 256), QByteArray::fromHex("f00000abcd00011000000200fff7")); } void dlive_opcodesDifferFromAvantis() { DLiveProbe p; // dLive channel level op 0x30, mute op 0x31 / plane 0x80, DCA plane 0x20 QCOMPARE(p.buildChannelLevel(H, 1, 0.0), - QByteArray::fromHex("f00000abcd0000300000028000f7")); + QByteArray::fromHex("f00000abcd0001300000028000f7")); QCOMPARE(p.buildChannelMute(H, 1, true), - QByteArray::fromHex("f00000abcd00003180000101f7")); // idx (0)+0x80 + QByteArray::fromHex("f00000abcd00013180000101f7")); // idx (0)+0x80 QCOMPARE(p.buildDcaMute(H, 1, true), - QByteArray::fromHex("f00000abcd00001020000101f7")); // idx (0)+0x20 + QByteArray::fromHex("f00000abcd00011020000101f7")); // idx (0)+0x20 } void emptyHandle_producesNoFrame() { @@ -151,9 +206,9 @@ class TestAllenHeathParsing : public QObject { GLDProbe p; // GLD level + mute share op 0x16 (mute plane 0x90); DCA mute op 0x10 plane 0x10 QCOMPARE(p.buildChannelLevel(H, 1, 0.0), - QByteArray::fromHex("f00000abcd0000160000028000f7")); - QCOMPARE(p.buildChannelMute(H, 1, true), QByteArray::fromHex("f00000abcd00001690000101f7")); - QCOMPARE(p.buildDcaMute(H, 1, true), QByteArray::fromHex("f00000abcd00001010000101f7")); + QByteArray::fromHex("f00000abcd0001160000028000f7")); + QCOMPARE(p.buildChannelMute(H, 1, true), QByteArray::fromHex("f00000abcd00011690000101f7")); + QCOMPARE(p.buildDcaMute(H, 1, true), QByteArray::fromHex("f00000abcd00011010000101f7")); } void gld_dbTableDiffersFromAce() { diff --git a/tests/test_console_discovery.cpp b/tests/test_console_discovery.cpp index dc37263..35669bf 100644 --- a/tests/test_console_discovery.cpp +++ b/tests/test_console_discovery.cpp @@ -89,6 +89,16 @@ QByteArray buildAceReply(const QByteArray& identity) { return frame; } +// what a console answers a by-name request with: the object's handle at [11..12]. +// The identity string only comes back when that handle is read. +QByteArray buildAceHandleReply(const QByteArray& handle) { + QByteArray frame = QByteArray::fromHex("f0000100000001000200"); + frame.append('\x02'); + frame.append(handle); + frame.append('\xf7'); + return frame; +} + DiscoveredConsole parseAce(const QByteArray& identity) { AllenHeathProbeStrategy strategy; return strategy.parseIdentifyResponse(buildAceReply(identity), QHostAddress("192.168.1.81"), @@ -439,22 +449,52 @@ class TestConsoleDiscovery : public QObject { QCOMPARE(count, 1); } + void service_normalizesV4MappedAddresses() { + // a dual-stack socket reports an IPv4 sender as ::ffff:a.b.c.d, and that + // literal string then reaches the driver as the host to connect to + ConsoleDiscoveryService service; + service.registerStrategy(std::make_shared()); + service.startScan(1000); + + DiscoveredConsole found; + connect(&service, &ConsoleDiscoveryService::consoleDiscovered, + [&](const DiscoveredConsole& c) { found = c; }); + + service.processDatagram( + buildOscReply("/xinfo", {"192.168.8.199", "MyDesk", "X32 RACK", "4.06"}), + QHostAddress("::ffff:192.168.8.199"), 10023); + + QTRY_VERIFY_WITH_TIMEOUT(found.isValid(), 2000); + QCOMPARE(found.address, QHostAddress("192.168.8.199")); + QCOMPARE(found.address.toString(), QString("192.168.8.199")); + } + void service_twoStageAceIdentify() { - // fake dLive responder on the ACE identify port; drives the UDP-reply -> - // TCP "DR Box Identification" -> reply-string path over real sockets + // fake dLive responder on the ACE identify port, sequencing the exchange + // the way a console does QTcpServer server; QVERIFY2(server.listen(QHostAddress::LocalHost, AllenHeathProbeStrategy::ACE_IDENTIFY_PORT), qPrintable(server.errorString())); - QByteArray requestSeen; - connect(&server, &QTcpServer::newConnection, [&server, &requestSeen]() { - QTcpSocket* conn = server.nextPendingConnection(); - connect(conn, &QTcpSocket::readyRead, [conn, &requestSeen]() { - requestSeen = conn->readAll(); - conn->write(buildAceReply("TLDDM32Stagebox V1.90 - Rev. 100")); - conn->flush(); - }); - }); + const QByteArray handle = QByteArray::fromHex("abcd"); + QByteArray nameRequestSeen; + QByteArray followUpSeen; + connect(&server, &QTcpServer::newConnection, + [&server, &nameRequestSeen, &followUpSeen, handle]() { + QTcpSocket* conn = server.nextPendingConnection(); + connect(conn, &QTcpSocket::readyRead, + [conn, &nameRequestSeen, &followUpSeen, handle]() { + const QByteArray req = conn->readAll(); + if (nameRequestSeen.isEmpty()) { + nameRequestSeen = req; + conn->write(buildAceHandleReply(handle)); + } else { + followUpSeen = req; + conn->write(buildAceReply("TLDDM32Stagebox V1.90 - Rev. 100")); + } + conn->flush(); + }); + }); ConsoleDiscoveryService service; service.registerStrategy(std::make_shared()); @@ -474,7 +514,8 @@ class TestConsoleDiscovery : public QObject { QCOMPARE(found.type, ConsoleType::DLive); QCOMPARE(found.modelName, QString("dLive DM32")); QCOMPARE(found.port, 51321); - QVERIFY(requestSeen.contains("DR Box Identification")); + QVERIFY(nameRequestSeen.contains("DR Box Identification")); + QCOMPARE(followUpSeen, AllenHeathProbeStrategy::buildAceHandleRead(handle)); } }; From 378b934f26964131ecfbdaba5892125dec369079 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Thu, 16 Jul 2026 23:56:17 -0400 Subject: [PATCH 03/11] fix(allenheath): give Qu its own message map Qu was inheriting SQ's driver, but the two share a transport and nothing else. Per the Qu Mixer MIDI Protocol V1.9+ (Qu-16/24/32): - The NRPN halves are swapped. Qu puts the channel in the MSB and the parameter id in the LSB (BN 63 CH, BN 62 ID); SQ is the other way round. Every fader, mute and DCA message was going out with the two bytes backwards. - Fader is ID 0x17 with a 7-bit value (0 dB = 0x62), not SQ's 14-bit pair. - Mute is Note On/Off, not an NRPN, so the console ignored ours entirely. - 4 DCA groups (0x10..0x13) and 100 scenes, not 8 and 300. - Scene recall sets both Bank Select MSB and LSB. Fader values come from the doc's Fader / Send Level table, reproduced exactly at every printed anchor. --- src/protocol/MixerCapabilities.cpp | 13 +-- src/protocol/allenheath/QuProtocol.cpp | 115 ++++++++++++++++++++++++- src/protocol/allenheath/QuProtocol.h | 31 ++++++- tests/test_mixer_capabilities.cpp | 3 +- 4 files changed, 150 insertions(+), 12 deletions(-) diff --git a/src/protocol/MixerCapabilities.cpp b/src/protocol/MixerCapabilities.cpp index b0ea754..67483c3 100644 --- a/src/protocol/MixerCapabilities.cpp +++ b/src/protocol/MixerCapabilities.cpp @@ -128,21 +128,22 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { caps.supportsEffectSends = true; break; - // Qu series shares the SQ MIDI-over-TCP control path (same "Soft" control - // family on port 51325). All Qu models run the same 32-channel DSP; only the - // physical fader count differs, so capabilities are identical across - // Qu-16/24/32 apart from the display name. + // Qu is MIDI over TCP 51325 like SQ, but a different message map (see + // QuProtocol). All Qu models run the same 32-channel DSP; only the physical + // fader count differs, so capabilities are identical across Qu-16/24/32 apart + // from the display name. Counts per the Qu MIDI Protocol V1.9+: 4 DCA groups, + // 100 scenes. case ConsoleType::Qu16: case ConsoleType::Qu24: case ConsoleType::Qu32: caps.manufacturer = Manufacturer::AllenHeath; caps.protocol = ProtocolType::MidiTcp; caps.defaultPort = 51325; - caps.dcaCount = 8; + caps.dcaCount = 4; caps.inputChannels = 32; caps.mixBuses = 12; caps.matrixOutputs = 0; - caps.scenes = 300; + caps.scenes = 100; caps.maxDCANameLength = 6; caps.eqBandsPerChannel = 4; caps.supportsChannelEQ = true; diff --git a/src/protocol/allenheath/QuProtocol.cpp b/src/protocol/allenheath/QuProtocol.cpp index 98e1a9d..be764e9 100644 --- a/src/protocol/allenheath/QuProtocol.cpp +++ b/src/protocol/allenheath/QuProtocol.cpp @@ -1,4 +1,6 @@ #include "QuProtocol.h" +#include +#include namespace OpenMix { @@ -10,7 +12,7 @@ QuProtocol::QuProtocol(const MixerCapabilities& caps, QObject* parent) void QuProtocol::initializeSnapshotParams() { m_snapshotParams.clear(); - for (int i = 1; i <= m_capabilities.dcaCount && i <= 8; ++i) { + for (int i = 1; i <= m_capabilities.dcaCount; ++i) { m_snapshotParams.append(dcaFaderPath(i)); m_snapshotParams.append(dcaMutePath(i)); } @@ -18,6 +20,7 @@ void QuProtocol::initializeSnapshotParams() { // input-channel faders, so a cue restores channel levels (not just DCAs) for (int i = 1; i <= m_capabilities.inputChannels; ++i) { m_snapshotParams.append(QString("/ch/%1/fader").arg(i)); + m_snapshotParams.append(QString("/ch/%1/mute").arg(i)); } } @@ -25,4 +28,114 @@ QString QuProtocol::dcaFaderPath(int dca) const { return QString("/dca/%1/fader" QString QuProtocol::dcaMutePath(int dca) const { return QString("/dca/%1/mute").arg(dca); } +namespace { + +// Fader / Send Level table (Qu MIDI Protocol V1.9+), as . Qu has no +// NRPN Fader Law setting: this one curve is the whole story. +struct LevelPoint { + double dB; + quint8 va; +}; + +constexpr LevelPoint kQuLevels[] = { + {-45, 0x0C}, {-40, 0x10}, {-35, 0x17}, {-30, 0x1F}, {-25, 0x27}, {-20, 0x2F}, + {-15, 0x36}, {-10, 0x3F}, {-5, 0x4F}, {0, 0x62}, {5, 0x72}, {10, 0x7F}, +}; + +} // namespace + +int QuProtocol::levelFromDb(double dB) { + if (dB <= NEG_INF_DB) { + return 0x00; + } + + const auto* first = std::begin(kQuLevels); + const auto* last = std::end(kQuLevels) - 1; + if (dB <= first->dB) { + return first->va; + } + if (dB >= last->dB) { + return last->va; + } + for (const LevelPoint* p = first; p < last; ++p) { + const LevelPoint* next = p + 1; + if (dB >= p->dB && dB <= next->dB) { + const double span = next->dB - p->dB; + const double t = span > 0.0 ? (dB - p->dB) / span : 0.0; + return static_cast(std::lround(p->va + t * (next->va - p->va))); + } + } + return last->va; +} + +QByteArray QuProtocol::buildFader(int channelId, double dB) { + // BN 63 | BN 62 17 | BN 06 | BN 26 07 + return buildNRPNMessage(0, channelId, ID_FADER, levelFromDb(dB), ID_FADER_VX); +} + +QByteArray QuProtocol::buildMute(int channelId, bool muted) { + // a Note On carrying the state, then a Note Off. Received velocity 40-7F + // mutes and 01-3F unmutes; velocity 0 and Note Off are ignored, so the state + // has to ride the first message. + QByteArray msg; + msg.append(static_cast(0x90)); + msg.append(static_cast(channelId & 0x7F)); + msg.append(static_cast(muted ? 0x7F : 0x3F)); + msg.append(static_cast(0x90)); + msg.append(static_cast(channelId & 0x7F)); + msg.append(static_cast(0x00)); + return msg; +} + +QByteArray QuProtocol::buildSceneRecall(int sceneNumber) { + // Bank Select MSB + LSB then the program; Qu has one bank of 100 scenes + if (sceneNumber < 1) { + return {}; + } + QByteArray msg; + msg.append(static_cast(0xB0)); + msg.append(static_cast(0x00)); + msg.append(static_cast(0x00)); + msg.append(static_cast(0xB0)); + msg.append(static_cast(0x20)); + msg.append(static_cast(0x00)); + msg.append(static_cast(0xC0)); + msg.append(static_cast((sceneNumber - 1) & 0x7F)); + return msg; +} + +void QuProtocol::sendParameter(const QString& path, const QVariant& value) { + if (!isConnected()) { + return; + } + + const QStringList parts = path.split('/', Qt::SkipEmptyParts); + if (parts.size() < 3) { + return; + } + + bool ok = false; + const int index = parts[1].toInt(&ok); + if (!ok || index < 1) { + return; + } + + int channelId = -1; + if (parts[0] == "ch" && index <= 32) { + channelId = CH_INPUT_BASE + index - 1; + } else if (parts[0] == "dca" && index <= 4) { + channelId = CH_DCA_BASE + index - 1; + } + if (channelId < 0) { + return; + } + + const QString param = parts[2]; + if (param == "fader") { + m_transport.send(buildFader(channelId, value.toDouble())); + } else if (param == "mute") { + m_transport.send(buildMute(channelId, value.toBool())); + } +} + } // namespace OpenMix diff --git a/src/protocol/allenheath/QuProtocol.h b/src/protocol/allenheath/QuProtocol.h index fdd9739..2fa2712 100644 --- a/src/protocol/allenheath/QuProtocol.h +++ b/src/protocol/allenheath/QuProtocol.h @@ -1,13 +1,18 @@ #pragma once +#include "../../core/LevelDb.h" #include "AllenHeathMidiProtocol.h" namespace OpenMix { -// Allen & Heath Qu series (Qu-16, Qu-24, Qu-32) protocol. -// Qu shares the SQ MIDI-over-TCP control path (port 51325); all Qu models run -// the same 32-channel DSP, differing only in physical fader count. -// 8 DCAs, 32 input channels, 12 mix buses. +// Allen & Heath Qu series (Qu-16, Qu-24, Qu-32) protocol, MIDI over TCP 51325. +// All Qu models run the same 32-channel DSP, differing only in fader count. +// +// Qu is not SQ with different numbers: the two NRPN halves are swapped. Qu puts +// the channel in the MSB and the parameter id in the LSB (BN 63 CH, BN 62 ID, +// BN 06 VA, BN 26 VX), levels are 7-bit, and mutes are Note On/Off rather than an +// NRPN. Verified against the Qu Mixer MIDI Protocol V1.9+ (Oct 2021), which +// covers Qu-16/24/32 alongside Qu-Pac and Qu-SB. class QuProtocol : public AllenHeathMidiProtocol { Q_OBJECT @@ -16,10 +21,28 @@ class QuProtocol : public AllenHeathMidiProtocol { QString protocolDescription() const override { return "Allen & Heath Qu MIDI/TCP Protocol"; } + void sendParameter(const QString& path, const QVariant& value) override; + protected: void initializeSnapshotParams() override; QString dcaFaderPath(int dca) const override; QString dcaMutePath(int dca) const override; + + // channel numbers, from the protocol doc's channel table + static constexpr int CH_INPUT_BASE = 0x20; // Input 1-32 = 20..3F + static constexpr int CH_DCA_BASE = 0x10; // DCA Group 1-4 = 10..13 + + // NRPN parameter ids + static constexpr int ID_FADER = 0x17; // VA = -inf..+10 dB = 00..7F + static constexpr int ID_FADER_VX = 0x07; // the data-entry LSB the fader wants + static constexpr int FADER_UNITY = 0x62; // 0 dB + + QByteArray buildFader(int channelId, double dB); + static QByteArray buildMute(int channelId, bool muted); + QByteArray buildSceneRecall(int sceneNumber) override; + + // dB -> the console's 7-bit level, through the Fader / Send Level table + static int levelFromDb(double dB); }; } // namespace OpenMix diff --git a/tests/test_mixer_capabilities.cpp b/tests/test_mixer_capabilities.cpp index b7dab31..8085169 100644 --- a/tests/test_mixer_capabilities.cpp +++ b/tests/test_mixer_capabilities.cpp @@ -41,7 +41,8 @@ class TestMixerCapabilities : public QObject { QCOMPARE(caps.protocol, ProtocolType::MidiTcp); QCOMPARE(caps.defaultPort, 51325); QCOMPARE(caps.inputChannels, 32); - QCOMPARE(caps.dcaCount, 8); + QCOMPARE(caps.dcaCount, 4); // Qu MIDI Protocol V1.9+: DCA Groups 1 to 4 + QCOMPARE(caps.scenes, 100); // ... and Scene 1 to 100 QCOMPARE(caps.mixBuses, 12); } // bare "qu" resolves to the base Qu-16 From 9291b1bba71dfe387b16a5088641ab735733c286 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Thu, 16 Jul 2026 23:56:30 -0400 Subject: [PATCH 04/11] fix(allenheath): correct the SQ DCA level param and follow the fader law DCA_LEVEL_LSB_BASE was 0x67, citing the SQ Level table. That number is not in it: 0x67 is the SQ+ value (SQ+ MIDI Protocol V2.0.3, which covers SQ5+/6+/7+, different hardware). SQ Issue 5 p24 and Qu Issue 2 p25 agree on 0x20, so every DCA fader move was addressing the wrong parameter. Levels also went out as a plain level*16383, which under the console's Linear Taper means a fader at unity sent -24.5 dB rather than 0 dB. Encode through the console's own law instead: - Linear Taper is exactly linear in dB (0 dB = 15196, 118.7 steps/dB) and reproduces every printed anchor within the doc's own rounding. - Audio Taper is a curve, so it carries the printed table and interpolates. The law is a console setting (Utility > General > MIDI > NRPN Fader Law) that cannot be read back, so it is selectable and defaults to Linear, the doc's standard mode. --- .../allenheath/AllenHeathMidiProtocol.cpp | 77 ++++++++- .../allenheath/AllenHeathMidiProtocol.h | 36 +++- tests/test_allenheath_midi.cpp | 157 +++++++++++++++++- 3 files changed, 248 insertions(+), 22 deletions(-) diff --git a/src/protocol/allenheath/AllenHeathMidiProtocol.cpp b/src/protocol/allenheath/AllenHeathMidiProtocol.cpp index 11851a0..8ad2f3d 100644 --- a/src/protocol/allenheath/AllenHeathMidiProtocol.cpp +++ b/src/protocol/allenheath/AllenHeathMidiProtocol.cpp @@ -67,7 +67,7 @@ void AllenHeathMidiProtocol::sendParameter(const QString& path, const QVariant& if (param == "fader") { // DCA level: 14-bit NRPN (SQ Issue 5, Master Sends/Control p24) - int midiValue = qBound(0, static_cast(value.toFloat() * 16383.0f), 16383); + const quint16 midiValue = encodeLevel14(value.toDouble()); QByteArray msg = buildNRPNMessage(0, DCA_LEVEL_MSB, DCA_LEVEL_LSB_BASE + dca - 1, (midiValue >> 7) & 0x7F, midiValue & 0x7F); m_transport.send(msg); @@ -86,7 +86,7 @@ void AllenHeathMidiProtocol::sendParameter(const QString& path, const QVariant& if (param == "fader") { // input-channel level to LR: 14-bit NRPN (SQ Issue 5 p22) - int midiValue = qBound(0, static_cast(value.toFloat() * 16383.0f), 16383); + const quint16 midiValue = encodeLevel14(value.toDouble()); QByteArray msg = buildNRPNMessage(0, CH_LEVEL_TO_LR_MSB, ch - 1, (midiValue >> 7) & 0x7F, midiValue & 0x7F); m_transport.send(msg); @@ -101,9 +101,9 @@ void AllenHeathMidiProtocol::sendParameter(const QString& path, const QVariant& m_parameterCache[path] = value; } -void AllenHeathMidiProtocol::setChannelFader(int channel, double level) { +void AllenHeathMidiProtocol::setChannelFaderDb(int channel, double dB) { // route through the path-based encoder above (keeps one wire-format code path) - sendParameter(QString("/ch/%1/fader").arg(channel), static_cast(level)); + sendParameter(QString("/ch/%1/fader").arg(channel), dB); } void AllenHeathMidiProtocol::setChannelMute(int channel, bool muted) { @@ -158,6 +158,75 @@ void AllenHeathMidiProtocol::refresh() { // console pushes updates automatically } +namespace { + +// Approximate Audio Taper Level Values (SQ MIDI Protocol Issue 5, p20), as +// . The curve is not expressible as a formula, unlike the linear +// taper, so the printed anchors are interpolated between. +struct TaperPoint { + double dB; + quint8 vc; + quint8 vf; +}; + +constexpr TaperPoint kAudioTaper[] = { + {-89, 0x01, 0x40}, {-85, 0x02, 0x00}, {-80, 0x02, 0x40}, {-75, 0x03, 0x40}, {-70, 0x04, 0x00}, + {-65, 0x05, 0x00}, {-60, 0x06, 0x00}, {-55, 0x07, 0x00}, {-50, 0x08, 0x00}, {-45, 0x0C, 0x00}, + {-40, 0x0F, 0x40}, {-38, 0x12, 0x40}, {-36, 0x15, 0x40}, {-35, 0x17, 0x00}, {-34, 0x19, 0x00}, + {-33, 0x1A, 0x40}, {-32, 0x1C, 0x00}, {-31, 0x1D, 0x40}, {-30, 0x1F, 0x00}, {-29, 0x20, 0x40}, + {-28, 0x22, 0x00}, {-27, 0x23, 0x40}, {-26, 0x25, 0x00}, {-25, 0x26, 0x40}, {-24, 0x28, 0x40}, + {-23, 0x2A, 0x00}, {-22, 0x2B, 0x40}, {-21, 0x2D, 0x00}, {-20, 0x2E, 0x40}, {-19, 0x30, 0x00}, + {-18, 0x31, 0x40}, {-17, 0x33, 0x00}, {-16, 0x34, 0x40}, {-15, 0x36, 0x00}, {-14, 0x38, 0x00}, + {-13, 0x39, 0x40}, {-12, 0x3B, 0x00}, {-11, 0x3C, 0x40}, {-10, 0x3E, 0x00}, {-9, 0x41, 0x40}, + {-8, 0x44, 0x40}, {-7, 0x48, 0x00}, {-6, 0x4B, 0x00}, {-5, 0x4E, 0x40}, {-4, 0x52, 0x40}, + {-3, 0x56, 0x40}, {-2, 0x5A, 0x00}, {-1, 0x5E, 0x00}, {0, 0x62, 0x00}, {1, 0x65, 0x40}, + {2, 0x69, 0x00}, {3, 0x6C, 0x40}, {4, 0x70, 0x00}, {5, 0x73, 0x40}, {6, 0x75, 0x40}, + {7, 0x78, 0x00}, {8, 0x7A, 0x40}, {9, 0x7D, 0x00}, {10, 0x7F, 0x40}, +}; + +quint16 taperValue(const TaperPoint& p) { return static_cast((p.vc << 7) | p.vf); } + +} // namespace + +quint16 AllenHeathMidiProtocol::encodeLinearTaper(double dB) { + // the printed table is exactly linear in dB: 0 dB = 15196, +10 dB = 16383, + // so 118.7 steps per dB. Anchors reproduce to within the doc's own rounding. + if (dB <= NEG_INF_DB) { + return 0; + } + const int v = static_cast(std::lround(15196.0 + 118.7 * dB)); + return static_cast(std::clamp(v, 0, 16383)); +} + +quint16 AllenHeathMidiProtocol::encodeAudioTaper(double dB) { + if (dB <= NEG_INF_DB) { + return 0; + } + const auto* first = std::begin(kAudioTaper); + const auto* last = std::end(kAudioTaper) - 1; + if (dB <= first->dB) { + return taperValue(*first); + } + if (dB >= last->dB) { + return taperValue(*last); + } + for (const TaperPoint* p = first; p < last; ++p) { + const TaperPoint* next = p + 1; + if (dB >= p->dB && dB <= next->dB) { + const double span = next->dB - p->dB; + const double t = span > 0.0 ? (dB - p->dB) / span : 0.0; + const double lo = taperValue(*p); + const double hi = taperValue(*next); + return static_cast(std::lround(lo + t * (hi - lo))); + } + } + return taperValue(*last); +} + +quint16 AllenHeathMidiProtocol::encodeLevel14(double dB) const { + return m_faderLaw == FaderLaw::AudioTaper ? encodeAudioTaper(dB) : encodeLinearTaper(dB); +} + QByteArray AllenHeathMidiProtocol::buildNRPNMessage(int channel, int nrpnMsb, int nrpnLsb, int valueMsb, int valueLsb) { QByteArray msg; diff --git a/src/protocol/allenheath/AllenHeathMidiProtocol.h b/src/protocol/allenheath/AllenHeathMidiProtocol.h index 6733be7..e8b5b59 100644 --- a/src/protocol/allenheath/AllenHeathMidiProtocol.h +++ b/src/protocol/allenheath/AllenHeathMidiProtocol.h @@ -1,5 +1,6 @@ #pragma once +#include "../../core/LevelDb.h" #include "../MixerCapabilities.h" #include "../MixerProtocol.h" #include "../transport/TcpTransport.h" @@ -72,30 +73,47 @@ class AllenHeathMidiProtocol : public MixerProtocol { [[nodiscard]] const MixerCapabilities& capabilities() const override { return m_capabilities; } // Input-channel fader level + mute via NRPN. Parameter numbers verified - // against the A&H SQ MIDI Protocol (Firmware V2.0.3) reference tables: - // Inputs-to-LR level MSB 0x40, input mute MSB 0x00, DCA level MSB 0x4F / - // LSB 0x67+, DCA mute MSB 0x02 (all LSB = 0-based item index). Levels are a - // 14-bit position; the console applies its own NRPN Fader Law (Linear/Audio - // Taper). Qu shares this map; GLD is close but should be checked per its doc. - void setChannelFader(int channel, double level) override; + // against the A&H SQ MIDI Protocol Issue 5 reference tables: inputs-to-LR + // level MSB 0x40, input mute MSB 0x00, DCA level MSB 0x4F / LSB 0x20+ (Mix + // Sends "Control" table), DCA mute MSB 0x02 (all LSB = 0-based item index). + void setChannelFaderDb(int channel, double dB) override; void setChannelMute(int channel, bool muted) override; + // Which curve the console maps NRPN levels through. This is a console-side + // setting (Utility > General > MIDI > NRPN Fader Law) that cannot be read back + // over MIDI, so the app has to be told which one the desk is in; a mismatch + // still moves the fader, just to the wrong dB. Linear Taper is the console's + // standard mode. + enum class FaderLaw { LinearTaper, AudioTaper }; + void setFaderLaw(FaderLaw law) { m_faderLaw = law; } + [[nodiscard]] FaderLaw faderLaw() const { return m_faderLaw; } + protected: + // dB -> the console's 14-bit NRPN level, through the active fader law + [[nodiscard]] quint16 encodeLevel14(double dB) const; + static quint16 encodeLinearTaper(double dB); + static quint16 encodeAudioTaper(double dB); + + // the dB value standing in for -inf; anything at or below encodes to zero + static constexpr double NEG_INF_DB = OpenMix::NEG_INF_DB; + // SQ NRPN parameter MSB; the LSB is the 0-based item index unless noted. static constexpr int CH_LEVEL_TO_LR_MSB = 0x40; // input N -> LR level (LSB = N-1) static constexpr int CH_MUTE_MSB = 0x00; // input N mute (LSB = N-1) - static constexpr int DCA_LEVEL_MSB = 0x4F; // DCA N level (LSB = 0x67 + N-1) - static constexpr int DCA_LEVEL_LSB_BASE = 0x67; // per A&H SQ MIDI Protocol, Level table + static constexpr int DCA_LEVEL_MSB = 0x4F; // DCA N level (LSB = 0x20 + N-1) + static constexpr int DCA_LEVEL_LSB_BASE = 0x20; // SQ Iss5 p24 / Qu Iss2 p25: DCA1 = 4F 20 static constexpr int DCA_MUTE_MSB = 0x02; // DCA N mute (LSB = N-1) // MIDI message builders used by subclasses QByteArray buildNRPNMessage(int channel, int nrpnMsb, int nrpnLsb, int valueMsb, int valueLsb); - QByteArray buildSceneRecall(int sceneNumber); + virtual QByteArray buildSceneRecall(int sceneNumber); QByteArray buildControlChange(int channel, int cc, int value); // parse incoming MIDI data virtual void parseMidiData(const QByteArray& data); + FaderLaw m_faderLaw = FaderLaw::LinearTaper; + // subclass-specific param mapping virtual void initializeSnapshotParams() = 0; virtual QString dcaFaderPath(int dca) const = 0; diff --git a/tests/test_allenheath_midi.cpp b/tests/test_allenheath_midi.cpp index 7c24084..5378986 100644 --- a/tests/test_allenheath_midi.cpp +++ b/tests/test_allenheath_midi.cpp @@ -1,16 +1,34 @@ +#include "core/Cue.h" #include "protocol/MixerCapabilities.h" #include "protocol/allenheath/AllenHeathMidiProtocol.h" +#include "protocol/allenheath/QuProtocol.h" #include using namespace OpenMix; namespace { +// exposes Qu's builders; Qu's map is unrelated to SQ's +class QuProbe : public QuProtocol { + public: + QuProbe() : QuProtocol(MixerCapabilities::forConsole(ConsoleType::Qu16)) {} + using QuProtocol::buildFader; + using QuProtocol::buildMute; + using QuProtocol::buildSceneRecall; + using QuProtocol::levelFromDb; +}; + // exposes the protected NRPN builder + satisfies the pure virtuals class SqProbe : public AllenHeathMidiProtocol { public: using AllenHeathMidiProtocol::AllenHeathMidiProtocol; using AllenHeathMidiProtocol::buildNRPNMessage; using AllenHeathMidiProtocol::buildSceneRecall; + using AllenHeathMidiProtocol::encodeAudioTaper; + using AllenHeathMidiProtocol::encodeLevel14; + using AllenHeathMidiProtocol::encodeLinearTaper; + using AllenHeathMidiProtocol::FaderLaw; + using AllenHeathMidiProtocol::setFaderLaw; + static constexpr double negInfDb() { return NEG_INF_DB; } static constexpr int dcaLevelMsb() { return DCA_LEVEL_MSB; } static constexpr int dcaLevelLsbBase() { return DCA_LEVEL_LSB_BASE; } static constexpr int dcaMuteMsb() { return DCA_MUTE_MSB; } @@ -22,9 +40,9 @@ class SqProbe : public AllenHeathMidiProtocol { }; } // namespace -// Verifies the SQ NRPN encoding against the byte examples printed in the official -// Allen & Heath SQ MIDI Protocol Issue 5 (p11), and the verified parameter -// numbers (Inputs-to-LR level p22). +// Byte examples come from the official protocol docs: SQ MIDI Protocol Issue 5 +// and Qu Mixer MIDI Protocol V1.9+. The two families share a transport and +// nothing else, so each has its own expectations here. class TestAllenHeathMidi : public QObject { Q_OBJECT @@ -56,19 +74,17 @@ class TestAllenHeathMidi : public QObject { } void dcaFader_usesVerifiedLevelParam() { - // A&H SQ MIDI Protocol Level table: DCA N level = MSB 0x4F, LSB 0x67 + N-1 + // SQ Iss5 p24 / Qu Iss2 p25 Mix Sends "Control": DCA1 = 4F 20 .. DCA8 = 4F 27 QCOMPARE(SqProbe::dcaLevelMsb(), 0x4F); - QCOMPARE(SqProbe::dcaLevelLsbBase(), 0x67); - // DCA1 fader at unity: MSB 0x4F, LSB 0x67 + QCOMPARE(SqProbe::dcaLevelLsbBase(), 0x20); QCOMPARE( p->buildNRPNMessage(0, SqProbe::dcaLevelMsb(), SqProbe::dcaLevelLsbBase(), 0x7F, 0x7F) .left(6), - QByteArray::fromHex("B0634FB06267")); - // DCA8 -> LSB 0x6E + QByteArray::fromHex("B0634FB06220")); QCOMPARE( p->buildNRPNMessage(0, SqProbe::dcaLevelMsb(), SqProbe::dcaLevelLsbBase() + 7, 0, 0) .left(6), - QByteArray::fromHex("B0634FB0626E")); + QByteArray::fromHex("B0634FB06227")); } void dcaMute_usesVerifiedParam() { @@ -89,6 +105,129 @@ class TestAllenHeathMidi : public QObject { QVERIFY(p->buildSceneRecall(0).isEmpty()); // 0 -> index -1 -> unset QVERIFY(p->buildSceneRecall(-1).isEmpty()); // explicit None } + + // --- fader laws (SQ Iss5 p20) --- + + void linearTaper_reproducesThePrintedTable() { + // every anchor the doc prints, as . The doc rounds its own + // values, so allow the 2-LSB slack that rounding introduces. + struct Row { + double dB; + quint8 vc; + quint8 vf; + }; + static const Row rows[] = { + {-89, 0x24, 0x16}, {-85, 0x27, 0x71}, {-80, 0x2C, 0x42}, {-75, 0x31, 0x14}, + {-70, 0x35, 0x65}, {-65, 0x3A, 0x37}, {-60, 0x3F, 0x09}, {-55, 0x43, 0x5A}, + {-50, 0x48, 0x2C}, {-45, 0x4C, 0x7D}, {-40, 0x51, 0x4F}, {-35, 0x56, 0x21}, + {-30, 0x5A, 0x72}, {-25, 0x5F, 0x44}, {-20, 0x64, 0x16}, {-15, 0x68, 0x67}, + {-10, 0x6D, 0x39}, {-5, 0x72, 0x0A}, {0, 0x76, 0x5C}, {5, 0x7B, 0x2E}, + {10, 0x7F, 0x7F}, + }; + for (const Row& r : rows) { + const int expected = (r.vc << 7) | r.vf; + const int actual = SqProbe::encodeLinearTaper(r.dB); + QVERIFY2( + std::abs(actual - expected) <= 2, + qPrintable( + QString("%1 dB: got %2, doc says %3").arg(r.dB).arg(actual).arg(expected))); + } + QCOMPARE(SqProbe::encodeLinearTaper(SqProbe::negInfDb()), quint16(0)); + } + + void audioTaper_reproducesThePrintedTable() { + // the audio taper is a curve, so its anchors must come back exactly + struct Row { + double dB; + quint8 vc; + quint8 vf; + }; + static const Row rows[] = { + {-45, 0x0C, 0x00}, {-40, 0x0F, 0x40}, {-30, 0x1F, 0x00}, {-20, 0x2E, 0x40}, + {-10, 0x3E, 0x00}, {-5, 0x4E, 0x40}, {-1, 0x5E, 0x00}, {0, 0x62, 0x00}, + {5, 0x73, 0x40}, {10, 0x7F, 0x40}, + }; + for (const Row& r : rows) { + QCOMPARE(SqProbe::encodeAudioTaper(r.dB), quint16((r.vc << 7) | r.vf)); + } + QCOMPARE(SqProbe::encodeAudioTaper(SqProbe::negInfDb()), quint16(0)); + } + + void faderLaw_selectsTheCurve() { + SqProbe probe(MixerCapabilities::forConsole(ConsoleType::Loopback)); + // linear is the console's standard mode, so it is the default + QCOMPARE(probe.faderLaw(), SqProbe::FaderLaw::LinearTaper); + QCOMPARE(probe.encodeLevel14(0.0), quint16((0x76 << 7) | 0x5C)); + probe.setFaderLaw(SqProbe::FaderLaw::AudioTaper); + QCOMPARE(probe.encodeLevel14(0.0), quint16(0x62 << 7)); + } + + // --- Qu, per the Qu Mixer MIDI Protocol V1.9+ --- + + void qu_faderPutsTheChannelInTheMsb() { + // BN 63 CH | BN 62 17 | BN 06 VA | BN 26 07 - the opposite of SQ, which + // puts the parameter in the MSB and the channel in the LSB + QuProbe q; + // Input 1 = CH 20 at unity: 0 dB = VA 62 + QCOMPARE(q.buildFader(0x20, 0.0), QByteArray::fromHex("B06320B06217B00662B02607")); + // Input 32 = CH 3F, fully down (-inf) / fully up (+10 dB) + QCOMPARE(q.buildFader(0x3F, Cue::NEG_INF_DB), + QByteArray::fromHex("B0633FB06217B00600B02607")); + QCOMPARE(q.buildFader(0x3F, 10.0), QByteArray::fromHex("B0633FB06217B0067FB02607")); + // DCA 1 = CH 10 + QCOMPARE(q.buildFader(0x10, 10.0), QByteArray::fromHex("B06310B06217B0067FB02607")); + } + + void qu_levelReproducesThePrintedTable() { + // Fader / Send Level table: every dB anchor the doc prints, exactly + struct Row { + double dB; + quint8 va; + }; + static const Row rows[] = { + {10, 0x7F}, {5, 0x72}, {0, 0x62}, {-5, 0x4F}, {-10, 0x3F}, {-15, 0x36}, + {-20, 0x2F}, {-25, 0x27}, {-30, 0x1F}, {-35, 0x17}, {-40, 0x10}, {-45, 0x0C}, + }; + for (const Row& r : rows) { + QCOMPARE(QuProbe::levelFromDb(r.dB), static_cast(r.va)); + } + QCOMPARE(QuProbe::levelFromDb(SqProbe::negInfDb()), 0x00); + // beyond the printed ends the console still clamps to the table + QCOMPARE(QuProbe::levelFromDb(20.0), 0x7F); + QCOMPARE(QuProbe::levelFromDb(-50.0), 0x0C); + } + + void qu_faderTakesDbDirectly() { + QuProbe q; + QCOMPARE(q.buildFader(0x20, 0.0), QByteArray::fromHex("B06320B06217B00662B02607")); + QCOMPARE(q.buildFader(0x20, Cue::NEG_INF_DB), + QByteArray::fromHex("B06320B06217B00600B02607")); + QCOMPARE(q.buildFader(0x20, 10.0), QByteArray::fromHex("B06320B06217B0067FB02607")); + } + + void qu_muteIsANoteNotAnNrpn() { + QuProbe q; + // mute on = velocity 7F, off = 3F; each followed by a note off + QCOMPARE(q.buildMute(0x20, true), QByteArray::fromHex("90207F902000")); + QCOMPARE(q.buildMute(0x20, false), QByteArray::fromHex("90203F902000")); + QCOMPARE(q.buildMute(0x13, true), QByteArray::fromHex("90137F901300")); + } + + void qu_sceneRecallSelectsBankOne() { + // B0 00 00, B0 20 00, C0 SS with scenes 1..100 = 00..63 + QuProbe q; + QCOMPARE(q.buildSceneRecall(1), QByteArray::fromHex("B00000B02000C000")); + QCOMPARE(q.buildSceneRecall(100), QByteArray::fromHex("B00000B02000C063")); + QVERIFY(q.buildSceneRecall(0).isEmpty()); + } + + void qu_capabilitiesMatchTheDoc() { + const auto caps = MixerCapabilities::forConsole(ConsoleType::Qu32); + QCOMPARE(caps.defaultPort, 51325); + QCOMPARE(caps.dcaCount, 4); // DCA Groups 1 to 4 + QCOMPARE(caps.scenes, 100); // Scene 1 to 100 + QCOMPARE(caps.inputChannels, 32); + } }; QTEST_MAIN(TestAllenHeathMidi) From b68b459db93477a4646fdeb846ab69431a3a1943 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Thu, 16 Jul 2026 23:56:43 -0400 Subject: [PATCH 05/11] fix(behringer): probe X32 with /info and give WING its real subscription X32: a console that answers /info but not /xinfo could never connect, because the connect gates on the /xinfo reply. The reference probes with /info, and /xinfo appears nowhere in it. Send both and connect on whichever lands. Renew /xremote every 4.5s as the reference does, not 8s: the console stops sending updates ~10s after the last renew, so at 8s a single dropped packet silently stalls all updates until the next one. The staleness window keeps its own constant rather than riding on the renew interval. WING: /$info and /$xremote do not exist. Identity is "/?", which returns WING,,,,,, and unsolicited updates need a "/*s~" subscription the console drops after 10s unless renewed. We were gating the connect on a reply that would never arrive and renewing a command that does not exist, so no updates could ever have arrived. Scene recall goes through the console's own $ctl/lib nodes; the OSC doc does not cover the show library, so that part is unconfirmed. Ports were right: X32 OSC/UDP 10023 and WING OSC/UDP 2223 are both documented. --- src/protocol/behringer/WingProtocol.cpp | 51 +++++++--------- src/protocol/behringer/WingProtocol.h | 30 ++++++---- src/protocol/behringer/X32Protocol.cpp | 77 +++++++++++++++++++++---- src/protocol/behringer/X32Protocol.h | 18 ++++-- 4 files changed, 120 insertions(+), 56 deletions(-) diff --git a/src/protocol/behringer/WingProtocol.cpp b/src/protocol/behringer/WingProtocol.cpp index 39ca5dc..5ba0da0 100644 --- a/src/protocol/behringer/WingProtocol.cpp +++ b/src/protocol/behringer/WingProtocol.cpp @@ -1,5 +1,6 @@ #include "WingProtocol.h" #include "../../core/Cue.h" +#include "../../core/LevelDb.h" #include #include @@ -136,7 +137,7 @@ bool WingProtocol::connect(const QString& host, int port) { // request device info to verify connection m_waitingForInfo = true; - m_transport.send("/$info"); + m_transport.send("/?"); m_connectionTimer.start(m_connectionTimeoutMs); @@ -219,16 +220,17 @@ void WingProtocol::recallScene(int sceneNumber) { if (m_connectionState != ConnectionState::Connected) return; - // WING scene recall via OSC - m_transport.send("/action/scenes/recall", sceneNumber); + // select the library entry, then act on it. These are the console's own node + // names; the OSC doc does not cover the show library, so this is unconfirmed + // against hardware. + m_transport.send("/$ctl/lib/$actionidx", sceneNumber); + m_transport.send("/$ctl/lib/$action", QString("GO")); } void WingProtocol::recallSnippet(int snippetNumber) { - if (m_connectionState != ConnectionState::Connected) - return; - - // WING snippet recall - m_transport.send("/-action/gosnippet", snippetNumber); + // the console's library is one list addressed by index; snippets are not a + // separate namespace as they are on X32 + recallScene(snippetNumber); } namespace { @@ -236,21 +238,12 @@ QString wingChannel(int channel) { return QString("/ch/%1").arg(channel); } QString wingBus(int bus) { return QString("/bus/%1").arg(bus); } -// WING faders carry real-world dB; map a normalized 0..1 level onto the exact -// X32/WING fader law (piecewise-linear in dB, per the Maillot/WING references): -// 0.0000-0.0625 -> -inf..-60, 0.0625-0.25 -> -60..-30, -// 0.25-0.5 -> -30..-10, 0.5-1.0 -> -10..+10 (0.75 = 0 dB). -float wingFaderDb(double level) { - level = std::clamp(level, 0.0, 1.0); - if (level <= 0.0) - return -144.0f; // -inf floor - if (level < 0.0625) - return static_cast(-60.0 - (0.0625 - level) / 0.0625 * 84.0); - if (level < 0.25) - return static_cast(level * 160.0 - 70.0); - if (level < 0.5) - return static_cast(level * 80.0 - 50.0); - return static_cast(level * 40.0 - 30.0); +// /fdr carries dB; -144 is the console's own -inf +float wingFaderDb(double dB) { + if (dB <= NEG_INF_DB) { + return -144.0f; + } + return static_cast(std::clamp(dB, -144.0, 10.0)); } // WING STD EQ exposes named bands l,1,2,3,4,h (not numbered 1..N) @@ -261,7 +254,7 @@ QString wingEqBand(int band) { } } // namespace -void WingProtocol::setChannelFader(int channel, double level) { +void WingProtocol::setChannelFaderDb(int channel, double level) { sendParameter(wingChannel(channel) + "/fdr", wingFaderDb(level)); } @@ -321,7 +314,7 @@ void WingProtocol::setDcaMute(int dca, bool muted) { sendParameter(QString("/dca/%1/mute").arg(dca), muted ? 1 : 0); } -void WingProtocol::setDcaFader(int dca, double level) { +void WingProtocol::setDcaFaderDb(int dca, double level) { sendParameter(QString("/dca/%1/fdr").arg(dca), wingFaderDb(level)); } @@ -404,7 +397,7 @@ void WingProtocol::onKeepAliveTimeout() { } // send keep-alive - m_transport.send("/$xremote"); + m_transport.send(SUBSCRIBE_COMMAND); } } @@ -471,7 +464,7 @@ void WingProtocol::onReconnectAttempt() { } m_waitingForInfo = true; - m_transport.send("/$info"); + m_transport.send("/?"); m_connectionTimer.start(m_connectionTimeoutMs); } @@ -479,7 +472,7 @@ void WingProtocol::processResponse(const QString& path, const QVariant& value) { qint64 now = QDateTime::currentMSecsSinceEpoch(); m_lastResponseTime = now; - if (path == "/$info" || path.startsWith("/$")) { + if (path == "/?") { handleInfoResponse(value); return; } @@ -514,7 +507,7 @@ void WingProtocol::handleInfoResponse([[maybe_unused]] const QVariant& value) { m_reconnectAttempts = 0; // subscribe to updates - m_transport.send("/$xremote"); + m_transport.send(SUBSCRIBE_COMMAND); requestDcaMembership(); diff --git a/src/protocol/behringer/WingProtocol.h b/src/protocol/behringer/WingProtocol.h index e86d015..fde699e 100644 --- a/src/protocol/behringer/WingProtocol.h +++ b/src/protocol/behringer/WingProtocol.h @@ -17,11 +17,13 @@ struct WingPendingRequest { qint64 sentTime; }; -// Behringer WING OSC protocol implementation -// WING uses OSC but with different namespace than X32: -// DCA paths: /dca/1-24/fader, /dca/1-24/mute -// scene recall: /action/scenes/recall,i with scene number -// defaults to port 2223 +// Behringer WING OSC protocol implementation (OSC over UDP 2223; the console's +// other interface, native binary on 2222, is not used here). +// +// "/?" returns "WING,,,,," and stands in for a +// connection check. Unsolicited updates require a subscription ("/*s~") that the +// console drops after 10 s unless renewed, and only one client may hold it. +// Paths follow the console's node tree: /ch/N/fdr, /ch/N/mute, /dca/N/fdr, ... class WingProtocol : public MixerProtocol { Q_OBJECT @@ -31,12 +33,16 @@ class WingProtocol : public MixerProtocol { // protocol identification [[nodiscard]] QString protocolName() const override { return m_capabilities.displayName; } - [[nodiscard]] QString protocolDescription() const override { return "Behringer WING OSC Protocol"; } + [[nodiscard]] QString protocolDescription() const override { + return "Behringer WING OSC Protocol"; + } // connection management [[nodiscard]] bool connect(const QString& host, int port) override; void disconnect() override; - [[nodiscard]] bool isConnected() const override { return m_connectionState == ConnectionState::Connected; } + [[nodiscard]] bool isConnected() const override { + return m_connectionState == ConnectionState::Connected; + } [[nodiscard]] QString connectionStatus() const override { return m_statusMessage; } [[nodiscard]] ConnectionState connectionState() const override { return m_connectionState; } @@ -56,7 +62,7 @@ class WingProtocol : public MixerProtocol { void recallSnippet(int snippetNumber) override; // semantic channel setters (WING uses real-world values, so most pass through) - void setChannelFader(int channel, double level) override; + void setChannelFaderDb(int channel, double level) override; void setChannelMute(int channel, bool muted) override; void setChannelPreamp(int channel, double gainDb) override; void setChannelHpf(int channel, bool on, double freqHz) override; @@ -71,7 +77,7 @@ class WingProtocol : public MixerProtocol { void setChannelColor(int channel, int color) override; void setDcaMute(int dca, bool muted) override; - void setDcaFader(int dca, double level) override; + void setDcaFaderDb(int dca, double level) override; void setDcaName(int dca, const QString& name) override; void setChannelDcaMask(int channel, quint32 mask) override; void setBusDcaMask(int bus, quint32 mask) override; @@ -116,9 +122,11 @@ class WingProtocol : public MixerProtocol { ConnectionState m_connectionState = ConnectionState::Disconnected; QString m_statusMessage; - // keep-alive timer (WING requires periodic messages) QTimer m_keepAliveTimer; - static constexpr int KEEPALIVE_INTERVAL = 8000; + // the subscription dies 10 s after the last request, so renew at 4.5 s: one + // dropped renew still leaves a further one inside the window + static constexpr int KEEPALIVE_INTERVAL = 4500; + static constexpr auto SUBSCRIBE_COMMAND = "/*s~"; // connection timeout QTimer m_connectionTimer; diff --git a/src/protocol/behringer/X32Protocol.cpp b/src/protocol/behringer/X32Protocol.cpp index b5c2c98..6ae5a0f 100644 --- a/src/protocol/behringer/X32Protocol.cpp +++ b/src/protocol/behringer/X32Protocol.cpp @@ -1,5 +1,6 @@ #include "X32Protocol.h" #include "../../core/Cue.h" +#include "../../core/LevelDb.h" #include #include #include @@ -24,6 +25,50 @@ QString x32Bus(int bus) { return QString("/bus/%1").arg(bus, 2, 10, QChar('0')); float clampUnit(double v) { return static_cast(std::clamp(v, 0.0, 1.0)); } +// X32 faders are the one wire format in OpenMix that is a position rather than a +// level: the console takes a 0..1 float and applies its own piecewise law. The +// segments are the published law (X32 OSC Remote Protocol, "level(float)"): +// 0..0.0625 = -oo/-90..-60 dB, 0.0625..0.25 = -60..-30, +// 0.25..0.5 = -30..-10, 0.5..1.0 = -10..+10. +float x32FaderFromDb(double dB) { + if (dB <= NEG_INF_DB || dB <= -90.0) { + return 0.0f; + } + double pos; + if (dB >= -10.0) { + pos = (dB + 30.0) / 40.0; // -10 .. +10 dB + } else if (dB >= -30.0) { + pos = (dB + 50.0) / 80.0; // -30 .. -10 dB + } else if (dB >= -60.0) { + pos = (dB + 70.0) / 160.0; // -60 .. -30 dB + } else { + pos = (dB + 90.0) / 480.0; // -90 .. -60 dB + } + return clampUnit(pos); +} + +// the paths whose value is a fader position rather than a real-world unit +bool isFaderPath(const QString& path) { + return path.endsWith("/mix/fader") || path.endsWith("/fader"); +} + +double x32DbFromFader(double position) { + position = std::clamp(position, 0.0, 1.0); + if (position <= 0.0) { + return NEG_INF_DB; + } + if (position >= 0.5) { + return position * 40.0 - 30.0; + } + if (position >= 0.25) { + return position * 80.0 - 50.0; + } + if (position >= 0.0625) { + return position * 160.0 - 70.0; + } + return position * 480.0 - 90.0; +} + float linNorm(double v, double lo, double hi) { return clampUnit((v - lo) / (hi - lo)); } float logNorm(double v, double lo, double hi) { @@ -36,7 +81,7 @@ float logNorm(double v, double lo, double hi) { // X32 compressor ratio is an enum index into a fixed table int x32RatioIndex(double ratio) { - static constexpr std::array ratios = {1.1, 1.3, 1.5, 2.0, 2.5, 3.0, + static constexpr std::array ratios = {1.1, 1.3, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 7.0, 10.0, 20.0, 100.0}; int best = 0; double bestDiff = 1e9; @@ -181,6 +226,9 @@ bool X32Protocol::connect(const QString& host, int port) { } m_waitingForXinfo = true; + // the reference probes with /info, and some consoles answer only that; /xinfo + // carries the richer payload, so ask for both and connect on whichever lands + m_transport.send("/info"); m_transport.send("/xinfo"); m_connectionTimer.start(m_connectionTimeoutMs); @@ -212,7 +260,7 @@ void X32Protocol::sendParameter(const QString& path, const QVariant& value) { return; m_transport.send(path, value); - m_parameterCache[path] = value; + m_parameterCache[path] = isFaderPath(path) ? QVariant(x32DbFromFader(value.toDouble())) : value; } QVariant X32Protocol::getParameter(const QString& path) { return m_parameterCache.value(path); } @@ -275,8 +323,8 @@ void X32Protocol::recallSnippet(int snippetNumber) { m_transport.send("/-action/gosnippet", snippetNumber); } -void X32Protocol::setChannelFader(int channel, double level) { - sendParameter(x32Channel(channel) + "/mix/fader", clampUnit(level)); +void X32Protocol::setChannelFaderDb(int channel, double dB) { + sendParameter(x32Channel(channel) + "/mix/fader", x32FaderFromDb(dB)); } std::optional X32Protocol::readChannelFader(int channel) { @@ -343,8 +391,8 @@ void X32Protocol::setDcaMute(int dca, bool muted) { sendParameter(QString("/dca/%1/on").arg(dca), muted ? 0 : 1); } -void X32Protocol::setDcaFader(int dca, double level) { - sendParameter(QString("/dca/%1/fader").arg(dca), clampUnit(level)); +void X32Protocol::setDcaFaderDb(int dca, double dB) { + sendParameter(QString("/dca/%1/fader").arg(dca), x32FaderFromDb(dB)); } void X32Protocol::setDcaName(int dca, const QString& name) { @@ -429,7 +477,7 @@ void X32Protocol::onMessageReceived(const QString& path, const QVariant& value) void X32Protocol::onKeepAliveTimeout() { if (m_connectionState == ConnectionState::Connected) { qint64 now = QDateTime::currentMSecsSinceEpoch(); - if (m_lastResponseTime > 0 && (now - m_lastResponseTime) > (KEEPALIVE_INTERVAL * 3)) { + if (m_lastResponseTime > 0 && (now - m_lastResponseTime) > RESPONSE_TIMEOUT) { setStatus("Connection lost - no response from mixer"); emit connectionLost(); startReconnection(); @@ -504,6 +552,9 @@ void X32Protocol::onReconnectAttempt() { } m_waitingForXinfo = true; + // the reference probes with /info, and some consoles answer only that; /xinfo + // carries the richer payload, so ask for both and connect on whichever lands + m_transport.send("/info"); m_transport.send("/xinfo"); m_connectionTimer.start(m_connectionTimeoutMs); } @@ -512,7 +563,7 @@ void X32Protocol::processResponse(const QString& path, const QVariant& value) { qint64 now = QDateTime::currentMSecsSinceEpoch(); m_lastResponseTime = now; - if (path == "/xinfo") { + if (path == "/xinfo" || path == "/info") { handleXinfoResponse(value); return; } @@ -535,7 +586,7 @@ void X32Protocol::processResponse(const QString& path, const QVariant& value) { static const QRegularExpression faderRe(QStringLiteral("^/ch/(\\d+)/mix/fader$")); const QRegularExpressionMatch faderMatch = faderRe.match(path); if (faderMatch.hasMatch() && value.isValid()) { - emit channelFaderChanged(faderMatch.captured(1).toInt(), value.toDouble()); + emit channelFaderChanged(faderMatch.captured(1).toInt(), x32DbFromFader(value.toDouble())); } if (m_pendingRequests.contains(path)) { @@ -549,8 +600,12 @@ void X32Protocol::processResponse(const QString& path, const QVariant& value) { } if (value.isValid()) { - m_parameterCache[path] = value; - emit parameterChanged(path, value); + // the console reports faders as positions; publish dB so consumers do not + // have to know which console they are looking at + const QVariant cached = + isFaderPath(path) ? QVariant(x32DbFromFader(value.toDouble())) : value; + m_parameterCache[path] = cached; + emit parameterChanged(path, cached); } } diff --git a/src/protocol/behringer/X32Protocol.h b/src/protocol/behringer/X32Protocol.h index 46433e2..5f5db92 100644 --- a/src/protocol/behringer/X32Protocol.h +++ b/src/protocol/behringer/X32Protocol.h @@ -34,7 +34,9 @@ class X32Protocol : public MixerProtocol { // connection management [[nodiscard]] bool connect(const QString& host, int port) override; void disconnect() override; - [[nodiscard]] bool isConnected() const override { return m_connectionState == ConnectionState::Connected; } + [[nodiscard]] bool isConnected() const override { + return m_connectionState == ConnectionState::Connected; + } [[nodiscard]] QString connectionStatus() const override { return m_statusMessage; } [[nodiscard]] ConnectionState connectionState() const override { return m_connectionState; } @@ -54,7 +56,7 @@ class X32Protocol : public MixerProtocol { void recallSnippet(int snippetNumber) override; // semantic channel setters (used by actor-voice recall and fades) - void setChannelFader(int channel, double level) override; + void setChannelFaderDb(int channel, double dB) override; [[nodiscard]] std::optional readChannelFader(int channel) override; void setChannelMute(int channel, bool muted) override; void setChannelPreamp(int channel, double gainDb) override; @@ -70,7 +72,7 @@ class X32Protocol : public MixerProtocol { void setChannelColor(int channel, int color) override; void setDcaMute(int dca, bool muted) override; - void setDcaFader(int dca, double level) override; + void setDcaFaderDb(int dca, double dB) override; void setDcaName(int dca, const QString& name) override; void setChannelDcaMask(int channel, quint32 mask) override; void setBusDcaMask(int bus, quint32 mask) override; @@ -127,9 +129,15 @@ class X32Protocol : public MixerProtocol { ConnectionState m_connectionState = ConnectionState::Disconnected; QString m_statusMessage; - // keep-alive timer (X32 requires /xremote every 10 seconds) + // The console stops sending updates ~10 s after the last /xremote, so renew at + // 4.5 s (as the reference does): a single dropped renew still leaves a further + // one inside the window, where an 8 s period would not. QTimer m_keepAliveTimer; - static constexpr int KEEPALIVE_INTERVAL = 8000; // 8s + static constexpr int KEEPALIVE_INTERVAL = 4500; + + // how long the console may go silent before the link counts as dead; metering + // streams continuously once subscribed, so silence this long is a real fault + static constexpr int RESPONSE_TIMEOUT = 24000; // connection timeout QTimer m_connectionTimer; From 3c94235362cf275ebce0e40ec282d0fc0cb569e7 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Thu, 16 Jul 2026 23:57:00 -0400 Subject: [PATCH 06/11] refactor(levels): store cue levels in dB rather than fader positions A 0..1 position is not a level until a console applies its fader law, so the same show file played back at different levels per desk: a 0.75 cue meant 0 dB on a dLive and -24.5 dB on an SQ. Carry dB from the cue to the wire instead, and let each driver encode it the way its console wants. This is what the reference does: its Allen & Heath drivers take the level as dB text, clamped to +10 and -95, with -999 standing in for -inf. - Cue stores dB under channelLevelsDb. Shows written before this still load: their positions convert through the law that was in force then, which is the best that can be done, since what a position meant depended on the console. - setChannelFader/setDcaFader become setChannelFaderDb/setDcaFaderDb, so the compiler finds every call site rather than silently reinterpreting it. - Deletes three invented curves: ACE now passes dB straight to its table, Yamaha is dB*100 centi-dB, and WING sends dB to /fdr (its curve was the X32 law copy-pasted). X32 keeps a conversion because its wire format really is a position; the segments are the published law. - Fades ramp to the bottom of the throw and only then go -inf, rather than diving through -900 dB. - CueEditor edits dB; DCAWidget and the X32 parameter cache follow. --- src/core/Cue.cpp | 37 +++++++++++++++--- src/core/Cue.h | 36 ++++++++++++----- src/core/LevelDb.h | 23 +++++++++++ src/core/PlaybackEngine.cpp | 21 +++++++--- src/protocol/LoopbackProtocol.cpp | 4 +- src/protocol/LoopbackProtocol.h | 4 +- src/protocol/MixerProtocol.cpp | 4 +- src/protocol/MixerProtocol.h | 13 ++++--- src/protocol/yamaha/YamahaProtocol.cpp | 34 +++++----------- src/protocol/yamaha/YamahaProtocol.h | 4 +- src/ui/CueEditor.cpp | 53 ++++++++++++++++--------- src/ui/DCAWidget.cpp | 54 +++++++++++++------------- src/ui/DCAWidget.h | 15 ++++--- src/ui/MixerFeedbackPanel.cpp | 5 ++- tests/test_cue.cpp | 35 +++++++++++++++++ tests/test_yamaha_scp.cpp | 31 ++++++++------- 16 files changed, 247 insertions(+), 126 deletions(-) create mode 100644 src/core/LevelDb.h diff --git a/src/core/Cue.cpp b/src/core/Cue.cpp index f893318..f749321 100644 --- a/src/core/Cue.cpp +++ b/src/core/Cue.cpp @@ -1,9 +1,26 @@ #include "Cue.h" #include "DCAMapping.h" #include +#include namespace OpenMix { +double Cue::dbFromLegacyPosition(double position) { + position = std::clamp(position, 0.0, 1.0); + if (position <= 0.0) { + return NEG_INF_DB; + } + if (position >= 1.0) { + return MAX_DB; + } + constexpr double unity = 0.75; + constexpr double floorDb = -60.0; + if (position < unity) { + return floorDb - (position / unity) * floorDb; + } + return (position - unity) / (1.0 - unity) * MAX_DB; +} + QString cueTypeToString(CueType type) { switch (type) { case CueType::Snapshot: @@ -409,13 +426,15 @@ QJsonObject Cue::toJson() const { json["channelProfiles"] = profilesObj; } - // per-channel level overrides: { "": level } + // per-channel level overrides: { "": dB }. Written under a key of its + // own: "channelLevels" holds 0..1 positions in shows written before levels + // were stored in dB, and the two cannot be told apart by value. if (!m_channelLevels.isEmpty()) { QJsonObject levelsObj; for (auto it = m_channelLevels.constBegin(); it != m_channelLevels.constEnd(); ++it) { levelsObj[QString::number(it.key())] = it.value(); } - json["channelLevels"] = levelsObj; + json["channelLevelsDb"] = levelsObj; } // named-position assignments @@ -563,12 +582,20 @@ Cue Cue::fromJson(const QJsonObject& json) { } } - // per-channel level overrides - if (json.contains("channelLevels")) { - const QJsonObject levelsObj = json["channelLevels"].toObject(); + // per-channel level overrides. Shows written before levels moved to dB carry + // 0..1 positions under the old key; convert them through the law that was in + // force then, which is the best that can be done - what a position meant in dB + // depended on the console it was played back on. + if (json.contains("channelLevelsDb")) { + const QJsonObject levelsObj = json["channelLevelsDb"].toObject(); for (auto it = levelsObj.constBegin(); it != levelsObj.constEnd(); ++it) { cue.m_channelLevels[it.key().toInt()] = it.value().toDouble(); } + } else if (json.contains("channelLevels")) { + const QJsonObject levelsObj = json["channelLevels"].toObject(); + for (auto it = levelsObj.constBegin(); it != levelsObj.constEnd(); ++it) { + cue.m_channelLevels[it.key().toInt()] = dbFromLegacyPosition(it.value().toDouble()); + } } // named-position assignments diff --git a/src/core/Cue.h b/src/core/Cue.h index 7f68252..f442b56 100644 --- a/src/core/Cue.h +++ b/src/core/Cue.h @@ -1,5 +1,7 @@ #pragma once +#include "LevelDb.h" + #include "FadeCurve.h" #include #include @@ -23,7 +25,9 @@ struct DCAOverride { std::optional mute; // mute state override (nullopt = don't change) std::optional label; // label override (nullopt = don't change) - [[nodiscard]] bool hasOverrides() const noexcept { return mute.has_value() || label.has_value(); } + [[nodiscard]] bool hasOverrides() const noexcept { + return mute.has_value() || label.has_value(); + } QJsonObject toJson() const; [[nodiscard]] static DCAOverride fromJson(const QJsonObject& json); @@ -163,7 +167,9 @@ class Cue { // named-position assignments (channel# -> Position id; resolved at playback // against the show's PositionLibrary to pan/delay sends) [[nodiscard]] QMap channelPositions() const { return m_channelPositions; } - void setChannelPositions(const QMap& positions) { m_channelPositions = positions; } + void setChannelPositions(const QMap& positions) { + m_channelPositions = positions; + } void setChannelPosition(int channel, const QString& positionId); [[nodiscard]] QString channelPosition(int channel) const { return m_channelPositions.value(channel); @@ -184,12 +190,24 @@ class Cue { void setChannelProfile(int channel, const QString& slot) { m_channelProfiles[channel] = slot; } void removeChannelProfile(int channel) { m_channelProfiles.remove(channel); } - // per-channel fader level override (channel -> 0..1) + // Per-channel fader level override, in dB (channel -> dB), as the console + // itself expresses levels. Storing dB rather than a 0..1 fader position keeps + // a cue meaning the same thing on every console: a position only becomes a + // level once a console-specific fader law is applied, so the same show file + // would otherwise play back at different levels per desk. [[nodiscard]] QMap channelLevels() const { return m_channelLevels; } void setChannelLevels(const QMap& levels) { m_channelLevels = levels; } - void setChannelLevel(int channel, double level) { m_channelLevels[channel] = level; } + void setChannelLevel(int channel, double dB) { m_channelLevels[channel] = dB; } void removeChannelLevel(int channel) { m_channelLevels.remove(channel); } + static constexpr double NEG_INF_DB = OpenMix::NEG_INF_DB; + static constexpr double MAX_DB = OpenMix::MAX_DB; + + // A 0..1 fader position under the law OpenMix applied before cues stored dB: + // unity at 3/4 travel, +10 dB at the top, -60 dB at the bottom of the useful + // throw. Only used to read shows written before the change. + static double dbFromLegacyPosition(double position); + // per-FX-unit mute state (fx unit index -> muted). Sent to the console on fire. [[nodiscard]] QMap fxMutes() const { return m_fxMutes; } void setFxMutes(const QMap& mutes) { m_fxMutes = mutes; } @@ -285,12 +303,12 @@ class Cue { QMap m_channelProfiles; // channel -> active profile slot id QMap m_channelLevels; // channel -> fader level override (0..1) - QMap m_fxMutes; // fx unit index -> muted - QList m_snippets; // console snippet indices recalled on fire - QList m_scenes; // console scene numbers recalled on fire + QMap m_fxMutes; // fx unit index -> muted + QList m_snippets; // console snippet indices recalled on fire + QList m_scenes; // console scene numbers recalled on fire QMap m_channelFX; // channel -> fx active - QString m_color; // display color (hex) - bool m_skip = false; // skip during standby advance + QString m_color; // display color (hex) + bool m_skip = false; // skip during standby advance }; } // namespace OpenMix diff --git a/src/core/LevelDb.h b/src/core/LevelDb.h new file mode 100644 index 0000000..baa8852 --- /dev/null +++ b/src/core/LevelDb.h @@ -0,0 +1,23 @@ +#pragma once + +namespace OpenMix { + +// Levels are carried in dB throughout OpenMix - in cues, across the protocol API, +// and into each driver, which encodes dB the way its console wants. A 0..1 fader +// position is not a level until a console-specific fader law is applied, so a +// position would make the same cue play back differently on different desks. +// +// dB below this read as -inf (fader fully down). JSON cannot carry an infinity +// and every console has its own -inf encoding, so the sentinel is a plain number +// below any console's floor. -999 and the +10 ceiling are the reference +// implementation's own values, which its Allen & Heath drivers clamp levels to. +inline constexpr double NEG_INF_DB = -999.0; + +// the top of the throw on every console OpenMix speaks to +inline constexpr double MAX_DB = 10.0; + +// the reference clamps levels to this floor before encoding, and treats anything +// under it as -inf +inline constexpr double MIN_DB = -95.0; + +} // namespace OpenMix diff --git a/src/core/PlaybackEngine.cpp b/src/core/PlaybackEngine.cpp index 0c9fe4d..1cdc0f1 100644 --- a/src/core/PlaybackEngine.cpp +++ b/src/core/PlaybackEngine.cpp @@ -3,6 +3,7 @@ #include "ActorProfileLibrary.h" #include "CueList.h" #include "DCAMapping.h" +#include "LevelDb.h" #include "PlaybackGuard.h" #include "Position.h" #include "protocol/MixerCapabilities.h" @@ -373,7 +374,7 @@ void PlaybackEngine::applyDCAOverrides(const Cue& cue, const QSet& targetDC // optional console behaviors, only on the muting edge if (muting && m_dimDcaFaders) - m_mixer->setDcaFader(dca, 0.0); + m_mixer->setDcaFaderDb(dca, 0.0); if (muting && m_muteDcaUnassign) clearDcaFromMembers(dca); } @@ -536,14 +537,21 @@ void PlaybackEngine::driveChannelLevel(int channel, double target, double fadeMs const double from = m_appliedChannelLevels.value(channel, target); - if (fadeMs > 0.0 && !qFuzzyCompare(from + 1.0, target + 1.0)) { - m_fadeEngine.start(QString("ch:%1").arg(channel), from, target, fadeMs, curve, + // Fades run in dB, but -inf is a sentinel far below the throw: ramping to it + // literally would dive through -900 dB and land as an abrupt cut. Ramp to the + // bottom of the throw instead, and let the last step become -inf. + const double rampFrom = std::max(from, MIN_DB); + const double rampTo = std::max(target, MIN_DB); + + if (fadeMs > 0.0 && !qFuzzyCompare(rampFrom + 1.0, rampTo + 1.0)) { + m_fadeEngine.start(QString("ch:%1").arg(channel), rampFrom, rampTo, fadeMs, curve, [this, channel](double v) { if (m_mixer) - m_mixer->setChannelFader(channel, v); + m_mixer->setChannelFaderDb(channel, + v <= MIN_DB ? NEG_INF_DB : v); }); } else { - m_mixer->setChannelFader(channel, target); + m_mixer->setChannelFaderDb(channel, target); } m_appliedChannelLevels[channel] = target; } @@ -666,7 +674,8 @@ void PlaybackEngine::verifyCue(int index, const Cue& cue) { int index; QStringList drifted; }; - auto acc = std::make_shared(Accumulator{static_cast(toVerify.size()), index, {}}); + auto acc = + std::make_shared(Accumulator{static_cast(toVerify.size()), index, {}}); for (const QString& path : toVerify) { const double expected = params.value(path).toDouble(); diff --git a/src/protocol/LoopbackProtocol.cpp b/src/protocol/LoopbackProtocol.cpp index ed01543..34c8efa 100644 --- a/src/protocol/LoopbackProtocol.cpp +++ b/src/protocol/LoopbackProtocol.cpp @@ -177,7 +177,7 @@ void LoopbackProtocol::refresh() { // do nothing } -void LoopbackProtocol::setChannelFader(int channel, double level) { +void LoopbackProtocol::setChannelFaderDb(int channel, double level) { m_recordedCalls.append(QString("fader:ch=%1:level=%2").arg(channel).arg(level)); } @@ -234,7 +234,7 @@ void LoopbackProtocol::setDcaMute(int dca, bool muted) { m_recordedCalls.append(QString("dcamute:dca=%1:muted=%2").arg(dca).arg(muted ? 1 : 0)); } -void LoopbackProtocol::setDcaFader(int dca, double level) { +void LoopbackProtocol::setDcaFaderDb(int dca, double level) { m_parameterState[QString("/dca/%1/fader").arg(dca)] = level; m_recordedCalls.append(QString("dcafader:dca=%1:level=%2").arg(dca).arg(level)); } diff --git a/src/protocol/LoopbackProtocol.h b/src/protocol/LoopbackProtocol.h index c4719db..998b843 100644 --- a/src/protocol/LoopbackProtocol.h +++ b/src/protocol/LoopbackProtocol.h @@ -43,7 +43,7 @@ class LoopbackProtocol : public MixerProtocol { void recallScene(int sceneNumber) override; // semantic channel setters; recorded as readable strings for testing - void setChannelFader(int channel, double level) override; + void setChannelFaderDb(int channel, double level) override; void setChannelMute(int channel, bool muted) override; void setChannelPreamp(int channel, double gainDb) override; void setChannelHpf(int channel, bool on, double freqHz) override; @@ -54,7 +54,7 @@ class LoopbackProtocol : public MixerProtocol { double releaseMs, double makeupDb) override; void setDcaMute(int dca, bool muted) override; - void setDcaFader(int dca, double level) override; + void setDcaFaderDb(int dca, double level) override; void setDcaName(int dca, const QString& name) override; void setChannelDcaMask(int channel, quint32 mask) override; void setBusDcaMask(int bus, quint32 mask) override; diff --git a/src/protocol/MixerProtocol.cpp b/src/protocol/MixerProtocol.cpp index e4af8f3..6c48178 100644 --- a/src/protocol/MixerProtocol.cpp +++ b/src/protocol/MixerProtocol.cpp @@ -10,7 +10,7 @@ const MixerCapabilities& MixerProtocol::capabilities() const { // default no-op implementations of the semantic channel setters; drivers that // support direct channel control override the ones they can encode. -void MixerProtocol::setChannelFader(int, double) {} +void MixerProtocol::setChannelFaderDb(int, double) {} void MixerProtocol::setChannelMute(int, bool) {} void MixerProtocol::setChannelPreamp(int, double) {} void MixerProtocol::setChannelHpf(int, bool, double) {} @@ -23,7 +23,7 @@ void MixerProtocol::setChannelColor(int, int) {} void MixerProtocol::setDcaMute(int dca, bool muted) { sendParameter(QStringLiteral("/dca/%1/mute").arg(dca), muted ? 1 : 0); } -void MixerProtocol::setDcaFader(int dca, double level) { +void MixerProtocol::setDcaFaderDb(int dca, double level) { sendParameter(QStringLiteral("/dca/%1/fader").arg(dca), level); } void MixerProtocol::setDcaName(int dca, const QString& name) { diff --git a/src/protocol/MixerProtocol.h b/src/protocol/MixerProtocol.h index 7e8f307..a40a5f7 100644 --- a/src/protocol/MixerProtocol.h +++ b/src/protocol/MixerProtocol.h @@ -47,9 +47,10 @@ class MixerProtocol : public QObject { // semantic per-channel setters used by actor-voice recall and timed fades. // Default to no-op so drivers opt in; network OSC drivers (X32/Wing) override. - // channel is 1-based; level is normalized 0..1; other units are real-world - // (dB, Hz, ms) and the driver scales them to the console wire format. - virtual void setChannelFader(int channel, double level); + // channel is 1-based; every unit here is real-world (dB, Hz, ms) and the + // driver encodes it the way its console wants. Levels are dB, with + // OpenMix::NEG_INF_DB meaning the fader is fully down. + virtual void setChannelFaderDb(int channel, double level); virtual void setChannelMute(int channel, bool muted); virtual void setChannelPreamp(int channel, double gainDb); virtual void setChannelHpf(int channel, bool on, double freqHz); @@ -67,7 +68,7 @@ class MixerProtocol : public QObject { // Defaults send generic /dca paths the A&H MIDI driver parses into NRPN; OSC // drivers override (e.g. X32 mute is /dca/N/on, not /dca/N/mute). virtual void setDcaMute(int dca, bool muted); - virtual void setDcaFader(int dca, double level); + virtual void setDcaFaderDb(int dca, double level); virtual void setDcaName(int dca, const QString& name); // DCA-group membership bitmask, bit d-1 = member of DCA d. Default no-op / nullopt. @@ -80,7 +81,7 @@ class MixerProtocol : public QObject { return std::nullopt; } - // read back a channel's current fader (normalized 0..1) from the driver's + // read back a channel's current fader (dB) from the driver's // parameter cache, or nullopt if unsupported / not yet known. Used to capture // live console levels into a cue. channel is 1-based. [[nodiscard]] virtual std::optional readChannelFader(int /*channel*/) { @@ -111,7 +112,7 @@ class MixerProtocol : public QObject { void parameterChanged(const QString& path, const QVariant& value); // a console snippet (isScene=false) or scene (isScene=true) name was read. void consoleNameReceived(bool isScene, int index, const QString& name); - // a channel fader moved on the console (channel 1-based, level normalized 0..1). + // a channel fader moved on the console (channel 1-based, level in dB). void channelFaderChanged(int channel, double level); void requestTimeout(const QString& path); diff --git a/src/protocol/yamaha/YamahaProtocol.cpp b/src/protocol/yamaha/YamahaProtocol.cpp index 0875e1c..91b9655 100644 --- a/src/protocol/yamaha/YamahaProtocol.cpp +++ b/src/protocol/yamaha/YamahaProtocol.cpp @@ -1,5 +1,6 @@ #include "YamahaProtocol.h" #include "../../core/Cue.h" +#include "../../core/LevelDb.h" #include #include #include @@ -38,12 +39,7 @@ YamahaProtocol::~YamahaProtocol() { disconnect(); } // -------------------------------------------------------------------------- QByteArray YamahaProtocol::scpSet(const QString& address, int idx1, int idx2, int value) { - return QStringLiteral("set %1 %2 %3 %4\n") - .arg(address) - .arg(idx1) - .arg(idx2) - .arg(value) - .toUtf8(); + return QStringLiteral("set %1 %2 %3 %4\n").arg(address).arg(idx1).arg(idx2).arg(value).toUtf8(); } QByteArray YamahaProtocol::scpSet(const QString& address, int idx1, int idx2, @@ -68,23 +64,13 @@ QByteArray YamahaProtocol::scpSceneRecall(int sceneNumber) { // Pure value scaling. // -------------------------------------------------------------------------- -int YamahaProtocol::faderLevelToScp(double level) { - if (level <= 0.0) - return FADER_NEG_INF; // -inf - if (level >= 1.0) - return FADER_MAX_CENTIDB; // +10 dB - - // Two-segment, linear-in-dB approximation of the Yamaha fader law: - // 0.00 .. 0.75 -> -138 dB .. 0 dB (throw up to unity) - // 0.75 .. 1.00 -> 0 dB .. +10 dB (top of throw) - // The real console taper is finer near unity; this is a documented approx. - double db; - if (level >= 0.75) - db = (level - 0.75) / 0.25 * 10.0; - else - db = (level / 0.75) * 138.0 - 138.0; - - int centi = static_cast(std::lround(db * 100.0)); +int YamahaProtocol::faderLevelToScp(double dB) { + // SCP carries levels in centi-dB, so a cue's dB needs no curve at all - only + // the console's own -inf sentinel and range + if (dB <= NEG_INF_DB) { + return FADER_NEG_INF; + } + const int centi = static_cast(std::lround(dB * 100.0)); return std::clamp(centi, FADER_MIN_CENTIDB, FADER_MAX_CENTIDB); } @@ -165,7 +151,7 @@ QByteArray YamahaProtocol::buildChannelColor(int ch, int color) const { // public setters take a 1-based channel (MixerProtocol contract); the SCP // builders use the 0-based input-channel index. -void YamahaProtocol::setChannelFader(int ch, double level) { +void YamahaProtocol::setChannelFaderDb(int ch, double level) { sendCommand(buildChannelFader(ch - 1, level)); } diff --git a/src/protocol/yamaha/YamahaProtocol.h b/src/protocol/yamaha/YamahaProtocol.h index 60387a0..cb55f65 100644 --- a/src/protocol/yamaha/YamahaProtocol.h +++ b/src/protocol/yamaha/YamahaProtocol.h @@ -91,7 +91,7 @@ class YamahaProtocol : public MixerProtocol { // --- semantic input-channel setters. channel is 1-based (MixerProtocol // contract); converted to the 0-based SCP index internally. --- - void setChannelFader(int ch, double level) override; // level normalized 0..1 + void setChannelFaderDb(int ch, double dB) override; void setChannelMute(int ch, bool muted) override; void setChannelPreamp(int ch, double gainDb) override; void setChannelHpf(int ch, bool on, double freqHz) override; @@ -113,7 +113,7 @@ class YamahaProtocol : public MixerProtocol { [[nodiscard]] static QByteArray scpSceneRecall(int sceneNumber); // --- pure value scaling helpers. --- - [[nodiscard]] static int faderLevelToScp(double level0to1); // 0..1 -> centi-dB taper + [[nodiscard]] static int faderLevelToScp(double dB); // dB -> centi-dB, clamped [[nodiscard]] static int dbToCentiDb(double db); // dB -> centi-dB [[nodiscard]] static int hzToScpFreq(double hz); // Hz -> SCP freq (Hz * 10) [[nodiscard]] static int qToScp(double q); // Q -> SCP Q (Q * 1000) diff --git a/src/ui/CueEditor.cpp b/src/ui/CueEditor.cpp index 9d863e9..ecfd2f6 100644 --- a/src/ui/CueEditor.cpp +++ b/src/ui/CueEditor.cpp @@ -8,6 +8,7 @@ #include "core/CueList.h" #include "core/Ensemble.h" #include "core/FadeCurve.h" +#include "core/LevelDb.h" #include "core/PlaybackEngine.h" #include "core/Position.h" #include "core/Show.h" @@ -37,6 +38,21 @@ namespace OpenMix { +namespace { + +// The level spin runs from the bottom of the console's throw to +10 dB, with one +// step below the floor reserved for -inf: a fader all the way down is not "-95 dB", +// it is off, and the two encode differently on every console. +constexpr double kLevelSpinMinDb = MIN_DB - 1.0; + +double levelSpinToDb(double spinValue) { + return spinValue <= kLevelSpinMinDb ? NEG_INF_DB : spinValue; +} + +double levelSpinValue(double dB) { return dB <= MIN_DB ? kLevelSpinMinDb : dB; } + +} // namespace + EnsembleLibrary* CueEditor::ensembleLibrary() const { return (m_app && m_app->show()) ? m_app->show()->ensembleLibrary() : nullptr; } @@ -71,8 +87,7 @@ CueEditor::CueEditor(Application* app, QWidget* parent) : QWidget(parent), m_app } if (m_app) { connect(m_app, &Application::dcaCountChanged, this, &CueEditor::onDcaCountChanged); - connect(m_app, &Application::activeDcasChanged, this, - &CueEditor::applyActiveDcaVisibility); + connect(m_app, &Application::activeDcasChanged, this, &CueEditor::applyActiveDcaVisibility); } updateGangsUI(); } @@ -506,9 +521,9 @@ void CueEditor::updateFromCue() { : QString("%1 (%2)").arg(snippet).arg(name)); } m_snippetsEdit->setText(snippetStrs.join(", ")); - m_snippetsEdit->setToolTip( - snippetNamed.isEmpty() ? tr("Console snippets recalled when this cue fires") - : tr("Snippets: %1").arg(snippetNamed.join(", "))); + m_snippetsEdit->setToolTip(snippetNamed.isEmpty() + ? tr("Console snippets recalled when this cue fires") + : tr("Snippets: %1").arg(snippetNamed.join(", "))); // per-FX-unit mutes updateFxMutesUI(); @@ -828,12 +843,15 @@ void CueEditor::rebuildChannelTable() { [this, ch](bool on) { onChannelLevelToggled(ch, on); }); m_channelTable->setCellWidget(row, 3, setCheck); - auto* levelSpin = new QSpinBox(m_channelTable); - levelSpin->setRange(0, 100); - levelSpin->setSuffix(tr(" %")); + auto* levelSpin = new QDoubleSpinBox(m_channelTable); + levelSpin->setRange(kLevelSpinMinDb, MAX_DB); + levelSpin->setDecimals(1); + levelSpin->setSingleStep(0.5); + levelSpin->setSuffix(tr(" dB")); + levelSpin->setSpecialValueText(tr("-inf")); levelSpin->setEnabled(false); levelSpin->setProperty("channel", ch); - connect(levelSpin, QOverload::of(&QSpinBox::valueChanged), this, + connect(levelSpin, QOverload::of(&QDoubleSpinBox::valueChanged), this, [this, ch]() { onChannelLevelChanged(ch); }); m_channelTable->setCellWidget(row, 4, levelSpin); @@ -861,7 +879,7 @@ void CueEditor::populateChannelTable() { for (int row = 0; row < m_channelTable->rowCount(); ++row) { auto* combo = qobject_cast(m_channelTable->cellWidget(row, 2)); auto* check = qobject_cast(m_channelTable->cellWidget(row, 3)); - auto* spin = qobject_cast(m_channelTable->cellWidget(row, 4)); + auto* spin = qobject_cast(m_channelTable->cellWidget(row, 4)); if (!combo || !check || !spin) continue; const int ch = combo->property("channel").toInt(); @@ -872,8 +890,7 @@ void CueEditor::populateChannelTable() { const bool hasLevel = levels.contains(ch); check->setChecked(hasLevel); spin->setEnabled(hasLevel); - spin->setValue(hasLevel ? std::clamp(static_cast(levels.value(ch) * 100.0 + 0.5), 0, 100) - : 75); + spin->setValue(hasLevel ? levelSpinValue(levels.value(ch)) : 0.0); if (auto* posCombo = qobject_cast(m_channelTable->cellWidget(row, 5))) { const int posIdx = posCombo->findData(cue->channelPosition(ch)); @@ -978,9 +995,9 @@ void CueEditor::onChannelLevelToggled(int channel, bool on) { if (!cue || !m_channelTable) return; - QSpinBox* spin = nullptr; + QDoubleSpinBox* spin = nullptr; for (int row = 0; row < m_channelTable->rowCount(); ++row) { - auto* s = qobject_cast(m_channelTable->cellWidget(row, 4)); + auto* s = qobject_cast(m_channelTable->cellWidget(row, 4)); if (s && s->property("channel").toInt() == channel) { spin = s; break; @@ -990,7 +1007,7 @@ void CueEditor::onChannelLevelToggled(int channel, bool on) { spin->setEnabled(on); if (on) - cue->setChannelLevel(channel, (spin ? spin->value() : 75) / 100.0); + cue->setChannelLevel(channel, spin ? levelSpinToDb(spin->value()) : 0.0); else cue->removeChannelLevel(channel); @@ -1005,9 +1022,9 @@ void CueEditor::onChannelLevelChanged(int channel) { if (!cue || !m_channelTable) return; - QSpinBox* spin = nullptr; + QDoubleSpinBox* spin = nullptr; for (int row = 0; row < m_channelTable->rowCount(); ++row) { - auto* s = qobject_cast(m_channelTable->cellWidget(row, 4)); + auto* s = qobject_cast(m_channelTable->cellWidget(row, 4)); if (s && s->property("channel").toInt() == channel) { spin = s; break; @@ -1016,7 +1033,7 @@ void CueEditor::onChannelLevelChanged(int channel) { if (!spin || !spin->isEnabled()) return; - cue->setChannelLevel(channel, spin->value() / 100.0); + cue->setChannelLevel(channel, levelSpinToDb(spin->value())); m_app->show()->cueList()->updateCue(m_currentIndex, *cue); emit cueModified(); } diff --git a/src/ui/DCAWidget.cpp b/src/ui/DCAWidget.cpp index dbaa01b..05179f3 100644 --- a/src/ui/DCAWidget.cpp +++ b/src/ui/DCAWidget.cpp @@ -14,6 +14,9 @@ namespace OpenMix { +// below this the strip reads as off, matching the console's own throw +static constexpr double kStripFloorDb = -60.0; + DCAWidget::DCAWidget(int dcaNumber, QWidget* parent) : QWidget(parent), m_dcaNumber(dcaNumber) { setupUi(); } @@ -70,12 +73,25 @@ void DCAWidget::setupUi() { updateDisplay(); } -void DCAWidget::setLevel(float level) { - m_level = std::clamp(level, 0.0f, 1.0f); - m_faderSlider->setValue(static_cast(m_level * 1000)); +void DCAWidget::setLevelDb(float dB) { + m_levelDb = dB <= MIN_DB + ? static_cast(NEG_INF_DB) + : std::clamp(dB, static_cast(MIN_DB), static_cast(MAX_DB)); + m_faderSlider->setValue(sliderPosition(m_levelDb)); updateDisplay(); } +// where a level sits on the strip: a fader scale is a display choice, so unity +// sits at 3/4 of the throw the way it does on a console +int DCAWidget::sliderPosition(float dB) { + if (dB <= kStripFloorDb) { + return 0; + } + const double travel = + dB <= 0.0 ? (dB - kStripFloorDb) / -kStripFloorDb * 0.75 : 0.75 + (dB / MAX_DB) * 0.25; + return static_cast(std::clamp(travel, 0.0, 1.0) * 1000); +} + void DCAWidget::setMuted(bool muted) { m_muted = muted; m_muteButton->setChecked(muted); @@ -179,7 +195,7 @@ void DCAWidget::setEditMode(bool editMode) { if (m_editMode != editMode) { m_editMode = editMode; if (editMode) { - m_originalLevel = m_level; + m_originalLevelDb = m_levelDb; } update(); } @@ -193,7 +209,7 @@ void DCAWidget::setPreviewMode(bool preview) { } void DCAWidget::setOriginalLevel(float level) { - m_originalLevel = std::clamp(level, 0.0f, 1.0f); + m_originalLevelDb = std::clamp(level, 0.0f, 1.0f); update(); } @@ -201,31 +217,15 @@ QSize DCAWidget::sizeHint() const { return QSize(56, 184); } QSize DCAWidget::minimumSizeHint() const { return QSize(40, 120); } -QString DCAWidget::levelToDb(float level) const { - if (level <= 0.0f) { - return "-inf"; - } - - // x32 fader curve approximation - // 0.0 = -inf, 0.75 = 0dB, 1.0 = +10dB - double db; - if (level <= 0.75f) { - // -90dB to 0dB range - db = (level / 0.75f) * 90.0 - 90.0; - } else { - // 0dB to +10dB range - db = ((level - 0.75f) / 0.25f) * 10.0; - } - - if (db <= -60.0) { +QString DCAWidget::levelText(float dB) const { + if (dB <= kStripFloorDb) { return "-inf"; } - - return QString("%1").arg(db, 0, 'f', 1); + return QString("%1").arg(dB, 0, 'f', 1); } void DCAWidget::updateDisplay() { - m_levelLabel->setText(levelToDb(m_level) + " dB"); + m_levelLabel->setText(levelText(m_levelDb) + " dB"); // muted = red, unmuted = the shared inert look (not force-unmute green) m_muteButton->setStyleSheet( @@ -247,7 +247,7 @@ void DCAWidget::paintEvent(QPaintEvent* event) { painter.drawRect(rect().adjusted(1, 1, -1, -1)); // draw original level indicator if level differs - if (qAbs(m_level - m_originalLevel) > 0.01f && m_faderSlider) { + if (qAbs(m_levelDb - m_originalLevelDb) > 0.01f && m_faderSlider) { int sliderY = m_faderSlider->y(); int sliderHeight = m_faderSlider->height(); int sliderX = m_faderSlider->x(); @@ -255,7 +255,7 @@ void DCAWidget::paintEvent(QPaintEvent* event) { // calculate pos for original level marker int originalY = - sliderY + sliderHeight - static_cast(m_originalLevel * sliderHeight); + sliderY + sliderHeight - static_cast(m_originalLevelDb * sliderHeight); painter.setPen(QPen(Theme::color(Theme::Colors::AccentRed), 2)); painter.drawLine(sliderX - 4, originalY, sliderX + sliderWidth + 4, originalY); diff --git a/src/ui/DCAWidget.h b/src/ui/DCAWidget.h index a0cffac..3866edb 100644 --- a/src/ui/DCAWidget.h +++ b/src/ui/DCAWidget.h @@ -1,5 +1,7 @@ #pragma once +#include "core/LevelDb.h" + #include class QSlider; @@ -17,8 +19,8 @@ class DCAWidget : public QWidget { int dcaNumber() const { return m_dcaNumber; } - void setLevel(float level); - float level() const { return m_level; } + void setLevelDb(float dB); + float levelDb() const { return m_levelDb; } void setMuted(bool muted); bool isMuted() const { return m_muted; } @@ -49,7 +51,7 @@ class DCAWidget : public QWidget { bool isPreviewMode() const { return m_previewMode; } void setOriginalLevel(float level); - float originalLevel() const { return m_originalLevel; } + float originalLevel() const { return m_originalLevelDb; } QSize sizeHint() const override; QSize minimumSizeHint() const override; @@ -74,10 +76,11 @@ class DCAWidget : public QWidget { void updateNameDisplay(); void finishLabelEdit(); void cancelLabelEdit(); - QString levelToDb(float level) const; + QString levelText(float dB) const; + static int sliderPosition(float dB); int m_dcaNumber; - float m_level = 0.0f; + float m_levelDb = NEG_INF_DB; bool m_muted = false; QString m_mixerName; QString m_cueLabel; @@ -85,7 +88,7 @@ class DCAWidget : public QWidget { bool m_editMode = false; bool m_previewMode = false; bool m_labelEditEnabled = false; - float m_originalLevel = 0.0f; + float m_originalLevelDb = 0.0f; QSlider* m_faderSlider; QPushButton* m_muteButton; diff --git a/src/ui/MixerFeedbackPanel.cpp b/src/ui/MixerFeedbackPanel.cpp index 959770b..5c61487 100644 --- a/src/ui/MixerFeedbackPanel.cpp +++ b/src/ui/MixerFeedbackPanel.cpp @@ -4,6 +4,7 @@ #include "app/Application.h" #include "core/Cue.h" #include "core/CueList.h" +#include "core/LevelDb.h" #include "core/PlaybackEngine.h" #include "core/Show.h" #include "protocol/MixerProtocol.h" @@ -181,7 +182,7 @@ void MixerFeedbackPanel::onParameterChanged(const QString& path, const QVariant& DCAWidget* dca = m_dcaWidgets[number - 1]; if (param == "fader") { - dca->setLevel(value.toFloat()); + dca->setLevelDb(value.toFloat()); dca->setActive(true); QTimer::singleShot(500, [dca]() { dca->setActive(false); }); } else if (param == "on" || param == "mute") { @@ -225,7 +226,7 @@ void MixerFeedbackPanel::onMixerConnected() { void MixerFeedbackPanel::onMixerDisconnected() { for (DCAWidget* dca : m_dcaWidgets) { - dca->setLevel(0.0f); + dca->setLevelDb(static_cast(NEG_INF_DB)); dca->setMuted(false); dca->setMixerName(QString()); dca->setCueLabel(QString()); diff --git a/tests/test_cue.cpp b/tests/test_cue.cpp index c262cb7..e8bfa2f 100644 --- a/tests/test_cue.cpp +++ b/tests/test_cue.cpp @@ -252,6 +252,41 @@ class TestCue : public QObject { QCOMPARE(target.color(), QString("#ff0000")); } + void channelLevels_roundTripAsDb() { + Cue cue(1.0, "Levels"); + cue.setChannelLevel(1, 0.0); // unity + cue.setChannelLevel(2, -12.5); // a real trim + cue.setChannelLevel(3, Cue::NEG_INF_DB); // fader down + + const Cue back = Cue::fromJson(cue.toJson()); + QCOMPARE(back.channelLevels().value(1), 0.0); + QCOMPARE(back.channelLevels().value(2), -12.5); + QCOMPARE(back.channelLevels().value(3), Cue::NEG_INF_DB); + } + + void channelLevels_legacyPositionsBecomeDb() { + // shows written before levels moved to dB carry 0..1 positions under the + // old key, and must still load + QJsonObject json; + json["number"] = 1.0; + json["type"] = "snapshot"; + QJsonObject legacy; + legacy["1"] = 0.75; // unity under the old law + legacy["2"] = 1.0; // top of throw + legacy["3"] = 0.0; // fully down + json["channelLevels"] = legacy; + + const Cue cue = Cue::fromJson(json); + QCOMPARE(cue.channelLevels().value(1), 0.0); + QCOMPARE(cue.channelLevels().value(2), Cue::MAX_DB); + QCOMPARE(cue.channelLevels().value(3), Cue::NEG_INF_DB); + + // and a re-save writes dB under the new key, not positions under the old + const QJsonObject saved = cue.toJson(); + QVERIFY(saved.contains("channelLevelsDb")); + QVERIFY(!saved.contains("channelLevels")); + } + void testSwapContentKeepsIdentity() { Cue a(1.0, "A"); a.setColor("#111111"); diff --git a/tests/test_yamaha_scp.cpp b/tests/test_yamaha_scp.cpp index d71bb83..f404dad 100644 --- a/tests/test_yamaha_scp.cpp +++ b/tests/test_yamaha_scp.cpp @@ -1,3 +1,4 @@ +#include "core/LevelDb.h" #include "protocol/yamaha/YamahaDM7Protocol.h" #include "protocol/yamaha/YamahaProtocol.h" #include @@ -39,21 +40,23 @@ class TestYamahaScp : public QObject { } void scpSceneRecall_exactString() { - QCOMPARE(YamahaProtocol::scpSceneRecall(5), - QByteArray("ssrecall_ex MIXER:Lib/Scene 5\n")); + QCOMPARE(YamahaProtocol::scpSceneRecall(5), QByteArray("ssrecall_ex MIXER:Lib/Scene 5\n")); QCOMPARE(YamahaProtocol::scpSceneRecall(100), QByteArray("ssrecall_ex MIXER:Lib/Scene 100\n")); } // ---- value scaling ---- - void faderTaper_anchors() { - QCOMPARE(YamahaProtocol::faderLevelToScp(0.0), -32768); // -inf - QCOMPARE(YamahaProtocol::faderLevelToScp(0.75), 0); // 0 dB at unity - QCOMPARE(YamahaProtocol::faderLevelToScp(1.0), 1000); // +10 dB at top - // out-of-range clamps - QCOMPARE(YamahaProtocol::faderLevelToScp(-0.5), -32768); - QCOMPARE(YamahaProtocol::faderLevelToScp(2.0), 1000); + void faderLevel_isCentiDb() { + // SCP carries centi-dB, so a cue's dB needs no curve: only the console's + // -inf sentinel and its range + QCOMPARE(YamahaProtocol::faderLevelToScp(NEG_INF_DB), -32768); + QCOMPARE(YamahaProtocol::faderLevelToScp(0.0), 0); // 0 dB + QCOMPARE(YamahaProtocol::faderLevelToScp(10.0), 1000); // +10 dB, top of throw + QCOMPARE(YamahaProtocol::faderLevelToScp(-12.5), -1250); + // out-of-range clamps to the console's own limits + QCOMPARE(YamahaProtocol::faderLevelToScp(-150.0), -13800); + QCOMPARE(YamahaProtocol::faderLevelToScp(20.0), 1000); } void faderTaper_monotonic() { @@ -77,9 +80,9 @@ class TestYamahaScp : public QObject { void buildFader_usesZeroBasedIndex() { ScpProbe p; - QCOMPARE(p.buildChannelFader(0, 0.75), + QCOMPARE(p.buildChannelFader(0, 0.0), QByteArray("set MIXER:Current/InCh/Fader/Level 0 0 0\n")); - QCOMPARE(p.buildChannelFader(3, 1.0), + QCOMPARE(p.buildChannelFader(3, 10.0), QByteArray("set MIXER:Current/InCh/Fader/Level 3 0 1000\n")); } @@ -107,16 +110,14 @@ class TestYamahaScp : public QObject { void buildHpf() { ScpProbe p; - QCOMPARE(p.buildChannelHpfOn(1, true), - QByteArray("set MIXER:Current/InCh/HPF/On 1 0 1\n")); + QCOMPARE(p.buildChannelHpfOn(1, true), QByteArray("set MIXER:Current/InCh/HPF/On 1 0 1\n")); QCOMPARE(p.buildChannelHpfFreq(2, 100.0), QByteArray("set MIXER:Current/InCh/HPF/Freq 2 0 1000\n")); } void buildEqOnAndBands() { ScpProbe p; - QCOMPARE(p.buildChannelEqOn(0, true), - QByteArray("set MIXER:Current/InCh/PEQ/On 0 0 1\n")); + QCOMPARE(p.buildChannelEqOn(0, true), QByteArray("set MIXER:Current/InCh/PEQ/On 0 0 1\n")); // band on -> Bypass 0, band off -> Bypass 1 (idx2 = band) QCOMPARE(p.buildChannelEqBandBypass(0, 1, true), QByteArray("set MIXER:Current/InCh/PEQ/Band/Bypass 0 1 0\n")); From 19f9521d41148a012f3aa3f40a77d20c1319a852 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Thu, 16 Jul 2026 23:57:15 -0400 Subject: [PATCH 07/11] chore: bump version to 1.0.7 Test call sites follow the setChannelFaderDb/setDcaFaderDb rename. --- CMakeLists.txt | 2 +- tests/test_playback_dca_assign.cpp | 2 +- tests/test_show_control.cpp | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index ca22552..d237c53 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.16) project(OpenMix - VERSION 1.0.6 + VERSION 1.0.7 DESCRIPTION "Stage mixing software for live theatre that lets engineers program, automate, and run cues seamlessly across 30+ digital mixing consoles" LANGUAGES CXX ) diff --git a/tests/test_playback_dca_assign.cpp b/tests/test_playback_dca_assign.cpp index c502c92..df02476 100644 --- a/tests/test_playback_dca_assign.cpp +++ b/tests/test_playback_dca_assign.cpp @@ -217,7 +217,7 @@ class TestPlaybackDcaAssign : public QObject { void baseDefaults_useGenericPaths() { RecordingProtocol proto; proto.setDcaMute(3, true); - proto.setDcaFader(2, 0.5); + proto.setDcaFaderDb(2, 0.5); proto.setDcaName(1, "Band"); proto.setChannelDcaMask(5, 7); // default no-op diff --git a/tests/test_show_control.cpp b/tests/test_show_control.cpp index 8e90a7a..08123f0 100644 --- a/tests/test_show_control.cpp +++ b/tests/test_show_control.cpp @@ -46,7 +46,7 @@ class RecordingMixer : public MixerProtocol { void recallScene(int scene) override { calls << QString("scene:%1").arg(scene); } void recallSnippet(int snippet) override { calls << QString("snippet:%1").arg(snippet); } - void setChannelFader(int channel, double level) override { + void setChannelFaderDb(int channel, double level) override { calls << QString("fader:ch=%1:level=%2").arg(channel).arg(level); } From e739d5527d19398cc25a70ef5188a9a098cd3233 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Fri, 17 Jul 2026 00:04:35 -0400 Subject: [PATCH 08/11] feat(allenheath): let the show carry the SQ NRPN Fader Law The console maps NRPN levels through one of two curves, chosen at Utility > General > MIDI > NRPN Fader Law, and does not report which. Both curves were already implemented, but the driver always assumed Linear: on a desk set to Audio Taper every level was wrong. Carry the choice in MixerConfig so it travels with the show, expose it in the connection panel for SQ, and hand it to the driver on connect. Defaults to linear, the console's standard mode, so existing shows are unchanged. The combo only appears for SQ: Qu-16/24/32 has a single fixed curve and the setting means nothing to the other consoles. --- src/app/Application.cpp | 9 +++++++ src/core/Show.cpp | 4 ++- src/core/Show.h | 49 ++++++++++++++++++++++++++-------- src/ui/ConnectionPanel.cpp | 18 +++++++++++++ src/ui/ConnectionPanel.h | 2 ++ tests/test_allenheath_midi.cpp | 13 +++++++++ tests/test_show.cpp | 18 +++++++++++++ 7 files changed, 101 insertions(+), 12 deletions(-) diff --git a/src/app/Application.cpp b/src/app/Application.cpp index db4b13c..28b5c40 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -27,6 +27,7 @@ #include "midi/MidiInputManager.h" #include "protocol/MixerProtocol.h" #include "protocol/ProtocolFactory.h" +#include "protocol/allenheath/AllenHeathMidiProtocol.h" #include "protocol/allenheath/AllenHeathTcpProtocol.h" #include "protocol/discovery/ConsoleDiscoveryService.h" #include "protocol/discovery/DiscoveredConsole.h" @@ -374,6 +375,14 @@ void Application::connectToMixer(const QString& type, const QString& host, int p if (!m_mixer) return; + // the console cannot report which fader law it is in, so hand the driver the + // one the show says the desk is set to + if (auto* midi = dynamic_cast(m_mixer)) { + midi->setFaderLaw(m_show->mixerConfig().faderLaw == "audio" + ? AllenHeathMidiProtocol::FaderLaw::AudioTaper + : AllenHeathMidiProtocol::FaderLaw::LinearTaper); + } + setupMixerConnection(type, host, port); } diff --git a/src/core/Show.cpp b/src/core/Show.cpp index 435c103..d8b2471 100644 --- a/src/core/Show.cpp +++ b/src/core/Show.cpp @@ -11,6 +11,7 @@ QJsonObject MixerConfig::toJson() const { json["host"] = host; json["port"] = port; json["dcaCount"] = dcaCount; + json["faderLaw"] = faderLaw; return json; } @@ -20,12 +21,13 @@ MixerConfig MixerConfig::fromJson(const QJsonObject& json) { config.host = json["host"].toString(); config.port = json["port"].toInt(10023); config.dcaCount = json["dcaCount"].toInt(8); + config.faderLaw = json["faderLaw"].toString("linear"); return config; } bool MixerConfig::operator==(const MixerConfig& other) const { return type == other.type && host == other.host && port == other.port && - dcaCount == other.dcaCount; + dcaCount == other.dcaCount && faderLaw == other.faderLaw; } Show::Show(QObject* parent) diff --git a/src/core/Show.h b/src/core/Show.h index 465eb51..e021f25 100644 --- a/src/core/Show.h +++ b/src/core/Show.h @@ -1,8 +1,8 @@ #pragma once #include "ActorProfileLibrary.h" -#include "CueList.h" #include "ConsoleNameCache.h" +#include "CueList.h" #include "CueZero.h" #include "DCAMapping.h" #include "Ensemble.h" @@ -24,11 +24,17 @@ struct MixerConfig { static constexpr int DEFAULT_PORT = 10023; static constexpr int DEFAULT_DCA_COUNT = 8; - QString type; // protocol ID: "x32", "wing", "sq7", "cl5", etc. - QString host; // IP address or hostname + QString type; // protocol ID: "x32", "wing", "sq7", "cl5", etc. + QString host; // IP address or hostname int port = DEFAULT_PORT; int dcaCount = DEFAULT_DCA_COUNT; + // Which curve an SQ maps NRPN levels through. It is a console-side setting + // (Utility > General > MIDI > NRPN Fader Law) that cannot be read back, so + // the desk and the show have to agree: "linear" (the console's standard + // mode) or "audio". Ignored by consoles without the setting. + QString faderLaw = "linear"; + [[nodiscard]] bool operator==(const MixerConfig& other) const; [[nodiscard]] bool operator!=(const MixerConfig& other) const { return !(*this == other); } @@ -46,13 +52,22 @@ class Show : public QObject { void setName(const QString& name); [[nodiscard]] QString author() const { return m_author; } - void setAuthor(const QString& author) { m_author = author; checkModifiedState(); } + void setAuthor(const QString& author) { + m_author = author; + checkModifiedState(); + } [[nodiscard]] QString designer() const { return m_designer; } - void setDesigner(const QString& designer) { m_designer = designer; checkModifiedState(); } + void setDesigner(const QString& designer) { + m_designer = designer; + checkModifiedState(); + } [[nodiscard]] QString notes() const { return m_notes; } - void setNotes(const QString& notes) { m_notes = notes; checkModifiedState(); } + void setNotes(const QString& notes) { + m_notes = notes; + checkModifiedState(); + } [[nodiscard]] QString filePath() const { return m_filePath; } void setFilePath(const QString& path) { m_filePath = path; } @@ -111,13 +126,25 @@ class Show : public QObject { // DCA-apply behavior via PlaybackEngine; selectOnSpill and suppressBackupSwitch // are stored preferences the app reads. [[nodiscard]] bool dimDcaFaders() const noexcept { return m_dimDcaFaders; } - void setDimDcaFaders(bool on) { m_dimDcaFaders = on; checkModifiedState(); } + void setDimDcaFaders(bool on) { + m_dimDcaFaders = on; + checkModifiedState(); + } [[nodiscard]] bool selectOnSpill() const noexcept { return m_selectOnSpill; } - void setSelectOnSpill(bool on) { m_selectOnSpill = on; checkModifiedState(); } + void setSelectOnSpill(bool on) { + m_selectOnSpill = on; + checkModifiedState(); + } [[nodiscard]] bool muteDcaUnassign() const noexcept { return m_muteDcaUnassign; } - void setMuteDcaUnassign(bool on) { m_muteDcaUnassign = on; checkModifiedState(); } + void setMuteDcaUnassign(bool on) { + m_muteDcaUnassign = on; + checkModifiedState(); + } [[nodiscard]] bool suppressBackupSwitch() const noexcept { return m_suppressBackupSwitch; } - void setSuppressBackupSwitch(bool on) { m_suppressBackupSwitch = on; checkModifiedState(); } + void setSuppressBackupSwitch(bool on) { + m_suppressBackupSwitch = on; + checkModifiedState(); + } // DCAs OpenMix does not control: hidden from the cue list, greyed in the // mapping panel, and never written to on the console during playback. @@ -154,7 +181,7 @@ class Show : public QObject { QString m_filePath; CueList m_cueList; MixerConfig m_mixerConfig; - QList> m_channelGangs; // ganged input-channel pairs + QList> m_channelGangs; // ganged input-channel pairs QList> m_channelGangMeta; // per-gang (name, color), by index bool m_dimDcaFaders = false; diff --git a/src/ui/ConnectionPanel.cpp b/src/ui/ConnectionPanel.cpp index a9cbc8d..53e7adb 100644 --- a/src/ui/ConnectionPanel.cpp +++ b/src/ui/ConnectionPanel.cpp @@ -100,6 +100,16 @@ void ConnectionPanel::setupUi() { m_portEdit->setPlaceholderText(tr("10023")); // default X32 port formLayout->addRow(m_portLabel, m_portEdit); + // the console applies one of two curves to NRPN levels and cannot report + // which; the desk and the show have to be told the same thing + m_faderLawLabel = new QLabel(tr("NRPN Fader Law:"), this); + m_faderLawCombo = new QComboBox(this); + m_faderLawCombo->addItem(tr("Linear Taper"), "linear"); + m_faderLawCombo->addItem(tr("Audio Taper"), "audio"); + m_faderLawCombo->setToolTip( + tr("Must match the console: Utility > General > MIDI > NRPN Fader Law.")); + formLayout->addRow(m_faderLawLabel, m_faderLawCombo); + m_loopbackLabel = new QLabel(tr("No hardware connection required."), this); m_loopbackLabel->setStyleSheet("color: gray; font-style: italic;"); m_loopbackLabel->setVisible(false); @@ -350,6 +360,10 @@ void ConnectionPanel::onProtocolTypeChanged(int index) { m_portEdit->setVisible(!isLoopback); m_loopbackLabel->setVisible(isLoopback); + const bool hasFaderLaw = type.startsWith("sq"); + m_faderLawLabel->setVisible(hasFaderLaw); + m_faderLawCombo->setVisible(hasFaderLaw); + if (!isLoopback) { m_portEdit->setText(QString::number(caps.defaultPort)); } @@ -429,6 +443,9 @@ void ConnectionPanel::loadFromConfig() { m_hostEdit->setText(config.host); m_portEdit->setText(QString::number(config.port)); + + const int lawIdx = m_faderLawCombo->findData(config.faderLaw); + m_faderLawCombo->setCurrentIndex(lawIdx < 0 ? 0 : lawIdx); } void ConnectionPanel::saveToConfig() { @@ -437,6 +454,7 @@ void ConnectionPanel::saveToConfig() { config.host = m_hostEdit->text().trimmed(); config.port = m_portEdit->text().toInt(); config.dcaCount = MixerCapabilities::forProtocolId(config.type).dcaCount; + config.faderLaw = m_faderLawCombo->currentData().toString(); m_app->show()->setMixerConfig(config); } diff --git a/src/ui/ConnectionPanel.h b/src/ui/ConnectionPanel.h index fa92134..8f90f86 100644 --- a/src/ui/ConnectionPanel.h +++ b/src/ui/ConnectionPanel.h @@ -52,6 +52,8 @@ class ConnectionPanel : public QWidget { QLineEdit* m_portEdit; QLabel* m_hostLabel; QLabel* m_portLabel; + QComboBox* m_faderLawCombo; + QLabel* m_faderLawLabel; QLabel* m_loopbackLabel; QPushButton* m_connectButton; QPushButton* m_disconnectButton; diff --git a/tests/test_allenheath_midi.cpp b/tests/test_allenheath_midi.cpp index 5378986..64cc5ad 100644 --- a/tests/test_allenheath_midi.cpp +++ b/tests/test_allenheath_midi.cpp @@ -153,6 +153,19 @@ class TestAllenHeathMidi : public QObject { QCOMPARE(SqProbe::encodeAudioTaper(SqProbe::negInfDb()), quint16(0)); } + void faderLaw_reachesTheWire() { + // the setting exists so an operator can match the desk; prove it changes + // the bytes a fader move puts on the wire, not just an internal flag + SqProbe probe(MixerCapabilities::forConsole(ConsoleType::Loopback)); + probe.setFaderLaw(SqProbe::FaderLaw::LinearTaper); + const quint16 lin = probe.encodeLevel14(0.0); + probe.setFaderLaw(SqProbe::FaderLaw::AudioTaper); + const quint16 aud = probe.encodeLevel14(0.0); + QVERIFY(lin != aud); + QCOMPARE(lin, quint16((0x76 << 7) | 0x5C)); // doc p20 linear 0 dB + QCOMPARE(aud, quint16(0x62 << 7)); // doc p20 audio 0 dB + } + void faderLaw_selectsTheCurve() { SqProbe probe(MixerCapabilities::forConsole(ConsoleType::Loopback)); // linear is the console's standard mode, so it is the default diff --git a/tests/test_show.cpp b/tests/test_show.cpp index 55a1a86..d1b70a4 100644 --- a/tests/test_show.cpp +++ b/tests/test_show.cpp @@ -59,6 +59,24 @@ class TestShow : public QObject { QCOMPARE(spy.count(), 1); } + void mixerConfig_faderLawRoundTrips() { + // the console cannot report its NRPN Fader Law, so the show has to carry it + MixerConfig config; + config.type = "sq7"; + config.host = "192.168.1.70"; + config.faderLaw = "audio"; + + const MixerConfig back = MixerConfig::fromJson(config.toJson()); + QCOMPARE(back.faderLaw, QString("audio")); + QVERIFY(back == config); + + // shows written before the setting existed default to the console's + // standard mode rather than to nothing + QJsonObject legacy; + legacy["type"] = "sq7"; + QCOMPARE(MixerConfig::fromJson(legacy).faderLaw, QString("linear")); + } + void setMixerConfig_identicalConfig_isNoOp() { Show show; MixerConfig config = show.mixerConfig(); From 1bf308b9dc1ca86cfab9793e9d3303d40abb9a20 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Fri, 17 Jul 2026 00:18:04 -0400 Subject: [PATCH 09/11] feat(allenheath): move GLD onto its documented MIDI/TCP interface GLD was driven over the ACE binary protocol on 51321, sharing dLive's driver. Allen & Heath document a different interface for it, and that is the one to speak: MIDI over TCP 51325, per the GLD MIDI and TCP/IP Protocol V1.4. GLD sits between SQ and Qu and matches neither: - Like Qu, the channel goes in the NRPN MSB and mutes are Note On, but the fader NRPN carries no data-entry LSB: three messages, not four. - Its level table is its own. 0 dB is 0x6B, where Qu's is 0x62. - Names and colours are SysEx (header 03/06), not NRPN. - Channels: DCA 1-16 = 10..1F, Input 1-48 = 20..4F. The doc also corrects two counts we had wrong: 16 DCAs (we said 8) and 500 scenes in 4 banks (we said 250). The console stamps its MIDI channel into every message and cannot report it, so it has to match Setup / Control on the desk: carry it in MixerConfig alongside the SQ fader law, expose it for GLD in the connection panel, and hand it to the driver on connect. Discovery is unchanged: GLD still does not answer the ACE identify handshake, so it stays a connect-time selection. Every frame and the whole level table are tested against the doc's printed values. --- src/app/Application.cpp | 5 + src/core/Show.cpp | 5 +- src/core/Show.h | 4 + src/protocol/MixerCapabilities.cpp | 18 +- src/protocol/allenheath/GLDProtocol.cpp | 193 ++++++++++++++---- src/protocol/allenheath/GLDProtocol.h | 73 ++++--- .../probes/AllenHeathProbeStrategy.h | 6 +- src/ui/ConnectionPanel.cpp | 14 ++ src/ui/ConnectionPanel.h | 3 + tests/CMakeLists.txt | 2 +- tests/test_allenheath_midi.cpp | 75 +++++++ tests/test_allenheath_parsing.cpp | 29 --- tests/test_mixer_capabilities.cpp | 3 +- 13 files changed, 324 insertions(+), 106 deletions(-) diff --git a/src/app/Application.cpp b/src/app/Application.cpp index 28b5c40..5f49a43 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -29,6 +29,7 @@ #include "protocol/ProtocolFactory.h" #include "protocol/allenheath/AllenHeathMidiProtocol.h" #include "protocol/allenheath/AllenHeathTcpProtocol.h" +#include "protocol/allenheath/GLDProtocol.h" #include "protocol/discovery/ConsoleDiscoveryService.h" #include "protocol/discovery/DiscoveredConsole.h" #include "protocol/discovery/probes/AllenHeathProbeStrategy.h" @@ -383,6 +384,10 @@ void Application::connectToMixer(const QString& type, const QString& host, int p : AllenHeathMidiProtocol::FaderLaw::LinearTaper); } + if (auto* gld = dynamic_cast(m_mixer)) { + gld->setMidiChannel(m_show->mixerConfig().midiChannel); + } + setupMixerConnection(type, host, port); } diff --git a/src/core/Show.cpp b/src/core/Show.cpp index d8b2471..e1e4b5d 100644 --- a/src/core/Show.cpp +++ b/src/core/Show.cpp @@ -12,6 +12,7 @@ QJsonObject MixerConfig::toJson() const { json["port"] = port; json["dcaCount"] = dcaCount; json["faderLaw"] = faderLaw; + json["midiChannel"] = midiChannel; return json; } @@ -22,12 +23,14 @@ MixerConfig MixerConfig::fromJson(const QJsonObject& json) { config.port = json["port"].toInt(10023); config.dcaCount = json["dcaCount"].toInt(8); config.faderLaw = json["faderLaw"].toString("linear"); + config.midiChannel = json["midiChannel"].toInt(1); return config; } bool MixerConfig::operator==(const MixerConfig& other) const { return type == other.type && host == other.host && port == other.port && - dcaCount == other.dcaCount && faderLaw == other.faderLaw; + dcaCount == other.dcaCount && faderLaw == other.faderLaw && + midiChannel == other.midiChannel; } Show::Show(QObject* parent) diff --git a/src/core/Show.h b/src/core/Show.h index e021f25..0294d97 100644 --- a/src/core/Show.h +++ b/src/core/Show.h @@ -35,6 +35,10 @@ struct MixerConfig { // mode) or "audio". Ignored by consoles without the setting. QString faderLaw = "linear"; + // GLD stamps its MIDI channel into every message and cannot report it, so it + // has to match Setup / Control on the console. 1-16. + int midiChannel = 1; + [[nodiscard]] bool operator==(const MixerConfig& other) const; [[nodiscard]] bool operator!=(const MixerConfig& other) const { return !(*this == other); } diff --git a/src/protocol/MixerCapabilities.cpp b/src/protocol/MixerCapabilities.cpp index 67483c3..47665b8 100644 --- a/src/protocol/MixerCapabilities.cpp +++ b/src/protocol/MixerCapabilities.cpp @@ -162,17 +162,19 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { } break; + // GLD is MIDI over TCP 51325 per the GLD MIDI and TCP/IP Protocol V1.4, which + // also gives it 16 DCAs and 500 scenes (4 banks of 128). case ConsoleType::GLD80: caps.manufacturer = Manufacturer::AllenHeath; - caps.protocol = ProtocolType::BinaryTcp; + caps.protocol = ProtocolType::MidiTcp; caps.displayName = "Allen & Heath GLD-80"; caps.protocolId = "gld80"; - caps.defaultPort = 51321; - caps.dcaCount = 8; + caps.defaultPort = 51325; + caps.dcaCount = 16; caps.inputChannels = 48; caps.mixBuses = 20; caps.matrixOutputs = 4; - caps.scenes = 250; + caps.scenes = 500; caps.maxDCANameLength = 8; caps.eqBandsPerChannel = 4; caps.supportsChannelEQ = true; @@ -184,15 +186,15 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { case ConsoleType::GLD112: caps.manufacturer = Manufacturer::AllenHeath; - caps.protocol = ProtocolType::BinaryTcp; + caps.protocol = ProtocolType::MidiTcp; caps.displayName = "Allen & Heath GLD-112"; caps.protocolId = "gld112"; - caps.defaultPort = 51321; - caps.dcaCount = 8; + caps.defaultPort = 51325; + caps.dcaCount = 16; caps.inputChannels = 48; caps.mixBuses = 30; caps.matrixOutputs = 4; - caps.scenes = 250; + caps.scenes = 500; caps.maxDCANameLength = 8; caps.eqBandsPerChannel = 4; caps.supportsChannelEQ = true; diff --git a/src/protocol/allenheath/GLDProtocol.cpp b/src/protocol/allenheath/GLDProtocol.cpp index a81b15c..c7155e3 100644 --- a/src/protocol/allenheath/GLDProtocol.cpp +++ b/src/protocol/allenheath/GLDProtocol.cpp @@ -1,62 +1,179 @@ #include "GLDProtocol.h" +#include +#include +#include namespace OpenMix { +namespace { + +// Fader level table (GLD MIDI and TCP/IP Protocol V1.4), as . Distinct +// from Qu's table: 0 dB is 0x6B here and 0x62 there. +struct LevelPoint { + double dB; + quint8 lv; +}; + +constexpr LevelPoint kGldLevels[] = { + {-45, 0x11}, {-40, 0x1B}, {-35, 0x25}, {-30, 0x2F}, {-25, 0x39}, {-20, 0x43}, + {-15, 0x4D}, {-10, 0x57}, {-5, 0x61}, {0, 0x6B}, {5, 0x74}, {10, 0x7F}, +}; + +} // namespace + GLDProtocol::GLDProtocol(const MixerCapabilities& caps, QObject* parent) - : AllenHeathTcpProtocol(caps, parent), m_midiTransport(this) { + : AllenHeathMidiProtocol(caps, parent) { initializeSnapshotParams(); } -bool GLDProtocol::connect(const QString& host, int port) { - // secondary MIDI socket for scene recall (best-effort; property socket is the - // primary control channel) - (void)m_midiTransport.connect(host, GLD_MIDI_PORT); - return AllenHeathTcpProtocol::connect(host, port); +void GLDProtocol::setMidiChannel(int channel1To16) { + m_midiChannel = std::clamp(channel1To16, 1, 16) - 1; } -void GLDProtocol::disconnect() { - m_midiTransport.disconnect(); - AllenHeathTcpProtocol::disconnect(); +void GLDProtocol::initializeSnapshotParams() { + m_snapshotParams.clear(); + + for (int i = 1; i <= m_capabilities.dcaCount; ++i) { + m_snapshotParams.append(dcaFaderPath(i)); + m_snapshotParams.append(dcaMutePath(i)); + } + for (int i = 1; i <= m_capabilities.inputChannels; ++i) { + m_snapshotParams.append(QString("/ch/%1/fader").arg(i)); + m_snapshotParams.append(QString("/ch/%1/mute").arg(i)); + } } -void GLDProtocol::recallScene(int sceneNumber) { - if (sceneNumber < 1) { - return; +QString GLDProtocol::dcaFaderPath(int dca) const { return QString("/dca/%1/fader").arg(dca); } + +QString GLDProtocol::dcaMutePath(int dca) const { return QString("/dca/%1/mute").arg(dca); } + +int GLDProtocol::levelFromDb(double dB) { + if (dB <= NEG_INF_DB) { + return 0x00; + } + + const auto* first = std::begin(kGldLevels); + const auto* last = std::end(kGldLevels) - 1; + if (dB <= first->dB) { + return first->lv; + } + if (dB >= last->dB) { + return last->lv; + } + for (const LevelPoint* p = first; p < last; ++p) { + const LevelPoint* next = p + 1; + if (dB >= p->dB && dB <= next->dB) { + const double span = next->dB - p->dB; + const double t = span > 0.0 ? (dB - p->dB) / span : 0.0; + return static_cast(std::lround(p->lv + t * (next->lv - p->lv))); + } } - // A&H MMC scene recall over the MIDI socket (51325): - // F0 00 00 1A 50 10 01 00 04 00 F7 (scene is 0-based on the wire) - QByteArray msg = QByteArray::fromHex("f000001a50100100"); - msg.append(static_cast((sceneNumber - 1) & 0x7F)); - msg.append('\x04'); - msg.append('\0'); - msg.append('\xf7'); - m_midiTransport.send(msg); + return last->lv; } -QList GLDProtocol::subscribeObjects() const { - // GLD's identity object is "iGL Surface Identification"; it addresses channels - // and DCAs via the Input Mixer / DCA Levels and Mutes handles. - return {QByteArrayLiteral("DR Box Identification"), - QByteArrayLiteral("iGL Surface Identification"), kInputMixer, kDcaLevelsAndMutes}; +QByteArray GLDProtocol::sysexHeader() const { + QByteArray h = QByteArray::fromHex("f000001a50100100"); + h.append(static_cast(m_midiChannel & 0x0F)); + return h; } -QByteArray GLDProtocol::encodeDb(double dB) const { - // GLD dB tables (reference FUN_00415780; distinct from the Avantis/dLive twin) - static const quint8 kPosLo[10] = {0x00, 0x1B, 0x35, 0x4E, 0x68, 0x81, 0x9B, 0xB5, 0xCE, 0xE8}; - static const quint8 kNegLo[10] = {0x00, 0xE6, 0xCC, 0xB3, 0x99, 0x7F, 0x66, 0x4C, 0x33, 0x19}; - return encodeDbTables(dB, kPosLo, kNegLo); +QByteArray GLDProtocol::buildFader(int channelId, double dB) const { + // BN 63 CH | BN 62 17 | BN 06 LV - three messages: unlike SQ and Qu, the + // fader carries no data-entry LSB + const char status = static_cast(0xB0 | (m_midiChannel & 0x0F)); + QByteArray msg; + msg.append(status); + msg.append(static_cast(0x63)); + msg.append(static_cast(channelId & 0x7F)); + msg.append(status); + msg.append(static_cast(0x62)); + msg.append(static_cast(ID_FADER)); + msg.append(status); + msg.append(static_cast(0x06)); + msg.append(static_cast(levelFromDb(dB) & 0x7F)); + return msg; } -void GLDProtocol::initializeSnapshotParams() { - m_snapshotParams.clear(); +QByteArray GLDProtocol::buildMute(int channelId, bool muted) const { + // a Note On carrying the state, then a Note Off. Received velocity 40-7F + // mutes and 01-3F unmutes; velocity 0 and Note Off are ignored, so the state + // has to ride the first message. + const char status = static_cast(0x90 | (m_midiChannel & 0x0F)); + QByteArray msg; + msg.append(status); + msg.append(static_cast(channelId & 0x7F)); + msg.append(static_cast(muted ? 0x7F : 0x3F)); + msg.append(status); + msg.append(static_cast(channelId & 0x7F)); + msg.append(static_cast(0x00)); + return msg; +} - // GLD control covers channel level/mute and DCA mute - for (int i = 1; i <= m_capabilities.inputChannels; ++i) { - m_snapshotParams.append(QString("/ch/%1/fader").arg(i)); - m_snapshotParams.append(QString("/ch/%1/mute").arg(i)); +QByteArray GLDProtocol::buildName(int channelId, const QString& name) const { + // header + 03 CH F7 + QByteArray msg = sysexHeader(); + msg.append(static_cast(0x03)); + msg.append(static_cast(channelId & 0x7F)); + msg.append(name.left(MAX_NAME_LENGTH).toLatin1()); + msg.append(static_cast(0xF7)); + return msg; +} + +QByteArray GLDProtocol::buildColour(int channelId, int colour) const { + // header + 06 CH Col F7 + QByteArray msg = sysexHeader(); + msg.append(static_cast(0x06)); + msg.append(static_cast(channelId & 0x7F)); + msg.append(static_cast(std::clamp(colour, COLOUR_OFF, COLOUR_MAX))); + msg.append(static_cast(0xF7)); + return msg; +} + +void GLDProtocol::setChannelName(int channel, const QString& name) { + if (!isConnected() || channel < 1 || channel > m_capabilities.inputChannels) { + return; + } + m_transport.send(buildName(CH_INPUT_BASE + channel - 1, name)); +} + +void GLDProtocol::setChannelColor(int channel, int color) { + if (!isConnected() || channel < 1 || channel > m_capabilities.inputChannels) { + return; + } + m_transport.send(buildColour(CH_INPUT_BASE + channel - 1, color)); +} + +void GLDProtocol::sendParameter(const QString& path, const QVariant& value) { + if (!isConnected()) { + return; + } + + const QStringList parts = path.split('/', Qt::SkipEmptyParts); + if (parts.size() < 3) { + return; + } + + bool ok = false; + const int index = parts[1].toInt(&ok); + if (!ok || index < 1) { + return; + } + + int channelId = -1; + if (parts[0] == "ch" && index <= m_capabilities.inputChannels) { + channelId = CH_INPUT_BASE + index - 1; + } else if (parts[0] == "dca" && index <= m_capabilities.dcaCount) { + channelId = CH_DCA_BASE + index - 1; } - for (int i = 1; i <= m_capabilities.dcaCount && i <= 8; ++i) { - m_snapshotParams.append(QString("/dca/%1/mute").arg(i)); + if (channelId < 0) { + return; + } + + const QString param = parts[2]; + if (param == "fader") { + m_transport.send(buildFader(channelId, value.toDouble())); + } else if (param == "mute") { + m_transport.send(buildMute(channelId, value.toBool())); } } diff --git a/src/protocol/allenheath/GLDProtocol.h b/src/protocol/allenheath/GLDProtocol.h index fda5370..c1da2a0 100644 --- a/src/protocol/allenheath/GLDProtocol.h +++ b/src/protocol/allenheath/GLDProtocol.h @@ -1,42 +1,65 @@ #pragma once -#include "../transport/TcpTransport.h" -#include "AllenHeathTcpProtocol.h" +#include "AllenHeathMidiProtocol.h" namespace OpenMix { -// Allen & Heath GLD series (GLD-80, GLD-112). -// GLD is driven over the A&H "property protocol" (the same family as -// Avantis/dLive) on TCP 51321, with its own opcodes and dB table. Scene recall -// by number is issued as an A&H MMC SysEx over a secondary MIDI socket (51325), -// matching the reference GLDDriver's two-socket design. -class GLDProtocol : public AllenHeathTcpProtocol { +// Allen & Heath GLD series (GLD-80, GLD-112), MIDI over TCP 51325. +// +// Verified against the GLD MIDI and TCP/IP Protocol V1.4. GLD sits between SQ +// and Qu: like Qu it puts the channel in the NRPN MSB and mutes with Note On, +// but its fader NRPN carries no data-entry LSB, and its level table is its own +// (0 dB = 0x6B, where Qu's is 0x62). Names and colours are SysEx, not NRPN. +// +// The console's MIDI channel (Setup / Control) has to match the one used here: +// it appears in every message and cannot be read back. +class GLDProtocol : public AllenHeathMidiProtocol { Q_OBJECT public: explicit GLDProtocol(const MixerCapabilities& caps, QObject* parent = nullptr); - QString protocolDescription() const override { return "Allen & Heath GLD ACE Protocol"; } + QString protocolDescription() const override { return "Allen & Heath GLD MIDI/TCP Protocol"; } - [[nodiscard]] bool connect(const QString& host, int port) override; - void disconnect() override; - void recallScene(int sceneNumber) override; + void sendParameter(const QString& path, const QVariant& value) override; + void setChannelName(int channel, const QString& name) override; + void setChannelColor(int channel, int color) override; + + // MIDI channel 1-16 as set on the console; held 0-based, as it goes on the wire + void setMidiChannel(int channel1To16); + [[nodiscard]] int midiChannel() const { return m_midiChannel + 1; } protected: void initializeSnapshotParams() override; - QList subscribeObjects() const override; - QByteArray encodeDb(double dB) const override; - - // GLD property opcodes recovered from GLDDriver: channel level and mute share - // op 0x16 (mute lives at index plane 0x90); DCA mute is op 0x10 plane 0x10. - quint8 channelLevelOp() const override { return 0x16; } - quint8 channelMuteOp() const override { return 0x16; } - int channelMutePlane() const override { return 0x90; } - int dcaMutePlane() const override { return 0x10; } - - private: - TcpTransport m_midiTransport; // secondary MIDI socket for scene MMC - static constexpr int GLD_MIDI_PORT = 51325; + QString dcaFaderPath(int dca) const override; + QString dcaMutePath(int dca) const override; + + // channel numbers, from the protocol doc's table + static constexpr int CH_DCA_BASE = 0x10; // DCA 1-16 = 10..1F + static constexpr int CH_INPUT_BASE = 0x20; // Input 1-48 = 20..4F + + // NRPN parameter ids + static constexpr int ID_FADER = 0x17; + + // the console's colour palette, in its own order + static constexpr int COLOUR_OFF = 0x00; + static constexpr int COLOUR_MAX = 0x07; + + // names longer than this are the console's to reject, not ours to send + static constexpr int MAX_NAME_LENGTH = 8; + + QByteArray buildFader(int channelId, double dB) const; + QByteArray buildMute(int channelId, bool muted) const; + QByteArray buildName(int channelId, const QString& name) const; + QByteArray buildColour(int channelId, int colour) const; + + // F0 00 00 1A 50 10 <0N>, the prefix of every GLD SysEx + QByteArray sysexHeader() const; + + // dB -> the console's 7-bit level, through its Fader level table + static int levelFromDb(double dB); + + int m_midiChannel = 0; // 0-based; console MIDI channel 1 by default }; } // namespace OpenMix diff --git a/src/protocol/discovery/probes/AllenHeathProbeStrategy.h b/src/protocol/discovery/probes/AllenHeathProbeStrategy.h index 2571275..67aa4ac 100644 --- a/src/protocol/discovery/probes/AllenHeathProbeStrategy.h +++ b/src/protocol/discovery/probes/AllenHeathProbeStrategy.h @@ -11,10 +11,10 @@ namespace OpenMix { // * SQ family - TCP 51326, binary 7F 01 / 7F 02 frame with a model byte. // * ACE family - TCP 51321, SysEx "DR Box Identification" request. The reply is // a handle, not a name; reading that handle back returns the string that -// identifies dLive ("TLD..."), Avantis ("Bridge"/"Avantis Solo"), or GLD -// ("GLD..."). +// identifies dLive ("TLD...") or Avantis ("Bridge"/"Avantis Solo"). GLD does +// not answer this handshake and stays a connect-time selection. // Both are attempted; the one the device answers wins. Control ports differ -// (SQ/GLD MIDI-over-TCP 51325, Avantis/dLive ACE-TCP 51321). +// (SQ/Qu/GLD MIDI-over-TCP 51325, Avantis/dLive ACE-TCP 51321). class AllenHeathProbeStrategy : public OscProbeStrategy { public: int probePort() const override { return 51320; } diff --git a/src/ui/ConnectionPanel.cpp b/src/ui/ConnectionPanel.cpp index 53e7adb..c900b24 100644 --- a/src/ui/ConnectionPanel.cpp +++ b/src/ui/ConnectionPanel.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include namespace OpenMix { @@ -110,6 +111,12 @@ void ConnectionPanel::setupUi() { tr("Must match the console: Utility > General > MIDI > NRPN Fader Law.")); formLayout->addRow(m_faderLawLabel, m_faderLawCombo); + m_midiChannelLabel = new QLabel(tr("MIDI Channel:"), this); + m_midiChannelSpin = new QSpinBox(this); + m_midiChannelSpin->setRange(1, 16); + m_midiChannelSpin->setToolTip(tr("Must match the console: Setup / Control / MIDI channel.")); + formLayout->addRow(m_midiChannelLabel, m_midiChannelSpin); + m_loopbackLabel = new QLabel(tr("No hardware connection required."), this); m_loopbackLabel->setStyleSheet("color: gray; font-style: italic;"); m_loopbackLabel->setVisible(false); @@ -364,6 +371,11 @@ void ConnectionPanel::onProtocolTypeChanged(int index) { m_faderLawLabel->setVisible(hasFaderLaw); m_faderLawCombo->setVisible(hasFaderLaw); + // GLD stamps its MIDI channel into every message it sends + const bool hasMidiChannel = type.startsWith("gld"); + m_midiChannelLabel->setVisible(hasMidiChannel); + m_midiChannelSpin->setVisible(hasMidiChannel); + if (!isLoopback) { m_portEdit->setText(QString::number(caps.defaultPort)); } @@ -446,6 +458,7 @@ void ConnectionPanel::loadFromConfig() { const int lawIdx = m_faderLawCombo->findData(config.faderLaw); m_faderLawCombo->setCurrentIndex(lawIdx < 0 ? 0 : lawIdx); + m_midiChannelSpin->setValue(config.midiChannel); } void ConnectionPanel::saveToConfig() { @@ -455,6 +468,7 @@ void ConnectionPanel::saveToConfig() { config.port = m_portEdit->text().toInt(); config.dcaCount = MixerCapabilities::forProtocolId(config.type).dcaCount; config.faderLaw = m_faderLawCombo->currentData().toString(); + config.midiChannel = m_midiChannelSpin->value(); m_app->show()->setMixerConfig(config); } diff --git a/src/ui/ConnectionPanel.h b/src/ui/ConnectionPanel.h index 8f90f86..db8f680 100644 --- a/src/ui/ConnectionPanel.h +++ b/src/ui/ConnectionPanel.h @@ -7,6 +7,7 @@ class QLineEdit; class QComboBox; +class QSpinBox; class QPushButton; class QLabel; @@ -54,6 +55,8 @@ class ConnectionPanel : public QWidget { QLabel* m_portLabel; QComboBox* m_faderLawCombo; QLabel* m_faderLawLabel; + QSpinBox* m_midiChannelSpin; + QLabel* m_midiChannelLabel; QLabel* m_loopbackLabel; QPushButton* m_connectButton; QPushButton* m_disconnectButton; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 27fe334..1f02451 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -326,7 +326,6 @@ add_executable(test_allenheath_parsing ${CMAKE_SOURCE_DIR}/src/protocol/allenheath/AllenHeathTcpProtocol.cpp ${CMAKE_SOURCE_DIR}/src/protocol/allenheath/AvantisProtocol.cpp ${CMAKE_SOURCE_DIR}/src/protocol/allenheath/DLiveProtocol.cpp - ${CMAKE_SOURCE_DIR}/src/protocol/allenheath/GLDProtocol.cpp ${CMAKE_SOURCE_DIR}/src/protocol/transport/TcpTransport.cpp ${CMAKE_SOURCE_DIR}/src/protocol/MixerProtocol.cpp ${CMAKE_SOURCE_DIR}/src/protocol/MixerCapabilities.cpp @@ -374,6 +373,7 @@ add_test(NAME AllenHeathSessionTest COMMAND test_allenheath_session) add_executable(test_allenheath_midi test_allenheath_midi.cpp ${CMAKE_SOURCE_DIR}/src/protocol/allenheath/AllenHeathMidiProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/allenheath/GLDProtocol.cpp ${CMAKE_SOURCE_DIR}/src/protocol/allenheath/QuProtocol.cpp ${CMAKE_SOURCE_DIR}/src/protocol/transport/TcpTransport.cpp ${CMAKE_SOURCE_DIR}/src/protocol/MixerProtocol.cpp diff --git a/tests/test_allenheath_midi.cpp b/tests/test_allenheath_midi.cpp index 64cc5ad..ede027b 100644 --- a/tests/test_allenheath_midi.cpp +++ b/tests/test_allenheath_midi.cpp @@ -1,6 +1,7 @@ #include "core/Cue.h" #include "protocol/MixerCapabilities.h" #include "protocol/allenheath/AllenHeathMidiProtocol.h" +#include "protocol/allenheath/GLDProtocol.h" #include "protocol/allenheath/QuProtocol.h" #include @@ -17,6 +18,18 @@ class QuProbe : public QuProtocol { using QuProtocol::levelFromDb; }; +// exposes GLD's builders; GLD's map is its own again +class GldProbe : public GLDProtocol { + public: + GldProbe() : GLDProtocol(MixerCapabilities::forConsole(ConsoleType::GLD80)) {} + using GLDProtocol::buildColour; + using GLDProtocol::buildFader; + using GLDProtocol::buildMute; + using GLDProtocol::buildName; + using GLDProtocol::levelFromDb; + using GLDProtocol::setMidiChannel; +}; + // exposes the protected NRPN builder + satisfies the pure virtuals class SqProbe : public AllenHeathMidiProtocol { public: @@ -241,6 +254,68 @@ class TestAllenHeathMidi : public QObject { QCOMPARE(caps.scenes, 100); // Scene 1 to 100 QCOMPARE(caps.inputChannels, 32); } + + // --- GLD, per the GLD MIDI and TCP/IP Protocol V1.4 --- + + void gld_levelReproducesThePrintedTable() { + // its own table: 0 dB is 6B here, 62 on Qu + struct Row { + double dB; + quint8 lv; + }; + static const Row rows[] = { + {10, 0x7F}, {5, 0x74}, {0, 0x6B}, {-5, 0x61}, {-10, 0x57}, {-15, 0x4D}, + {-20, 0x43}, {-25, 0x39}, {-30, 0x2F}, {-35, 0x25}, {-40, 0x1B}, {-45, 0x11}, + }; + for (const Row& r : rows) { + QCOMPARE(GldProbe::levelFromDb(r.dB), static_cast(r.lv)); + } + QCOMPARE(GldProbe::levelFromDb(NEG_INF_DB), 0x00); + } + + void gld_faderCarriesNoDataEntryLsb() { + // BN 63 CH | BN 62 17 | BN 06 LV - three messages, where SQ and Qu send four + GldProbe g; + // Input 1 = CH 20 at unity + QCOMPARE(g.buildFader(0x20, 0.0), QByteArray::fromHex("B06320B06217B0066B")); + // DCA 1 = CH 10, fully down + QCOMPARE(g.buildFader(0x10, NEG_INF_DB), QByteArray::fromHex("B06310B06217B00600")); + } + + void gld_muteIsANote() { + GldProbe g; + QCOMPARE(g.buildMute(0x20, true), QByteArray::fromHex("90207F902000")); + QCOMPARE(g.buildMute(0x20, false), QByteArray::fromHex("90203F902000")); + } + + void gld_nameAndColourAreSysex() { + // header F0 00 00 1A 50 10 01 00 0N, then 03 CH F7 / 06 CH Col F7 + GldProbe g; + QCOMPARE(g.buildName(0x20, "Vox"), QByteArray::fromHex("f000001a50100100000320566f78f7")); + QCOMPARE(g.buildColour(0x20, 0x02), QByteArray::fromHex("f000001a5010010000062002f7")); + } + + void gld_midiChannelReachesEveryMessage() { + // the channel rides the status byte and the SysEx header, and the console + // cannot report it: a mismatch silently talks to nobody + GldProbe g; + g.setMidiChannel(3); // console channel 3 -> N = 2 + QCOMPARE(g.midiChannel(), 3); + QCOMPARE(g.buildFader(0x20, 0.0), QByteArray::fromHex("B26320B26217B2066B")); + QCOMPARE(g.buildMute(0x20, true), QByteArray::fromHex("92207F922000")); + QCOMPARE(g.buildColour(0x20, 0x02), QByteArray::fromHex("f000001a5010010002062002f7")); + + g.setMidiChannel(1); // the default: N = 0 + QCOMPARE(g.buildFader(0x20, 0.0), QByteArray::fromHex("B06320B06217B0066B")); + } + + void gld_capabilitiesMatchTheDoc() { + const auto caps = MixerCapabilities::forConsole(ConsoleType::GLD80); + QCOMPARE(caps.defaultPort, 51325); // MIDI over TCP, not ACE 51321 + QCOMPARE(caps.protocol, ProtocolType::MidiTcp); + QCOMPARE(caps.dcaCount, 16); // DCA 1 to 16 = 10..1F + QCOMPARE(caps.scenes, 500); // 500 scenes in 4 banks + } }; QTEST_MAIN(TestAllenHeathMidi) diff --git a/tests/test_allenheath_parsing.cpp b/tests/test_allenheath_parsing.cpp index b247cd3..e7cc1ad 100644 --- a/tests/test_allenheath_parsing.cpp +++ b/tests/test_allenheath_parsing.cpp @@ -1,7 +1,6 @@ #include "protocol/MixerCapabilities.h" #include "protocol/allenheath/AvantisProtocol.h" #include "protocol/allenheath/DLiveProtocol.h" -#include "protocol/allenheath/GLDProtocol.h" #include using namespace OpenMix; @@ -34,15 +33,6 @@ class DLiveProbe : public DLiveProtocol { using AllenHeathTcpProtocol::buildDcaMute; }; -class GLDProbe : public GLDProtocol { - public: - GLDProbe() : GLDProtocol(MixerCapabilities::forConsole(ConsoleType::GLD80)) {} - using AllenHeathTcpProtocol::buildChannelLevel; - using AllenHeathTcpProtocol::buildChannelMute; - using AllenHeathTcpProtocol::buildDcaMute; - using AllenHeathTcpProtocol::encodeDb; -}; - const QByteArray H = QByteArray::fromHex("abcd"); // mock learned handle } // namespace @@ -201,25 +191,6 @@ class TestAllenHeathParsing : public QObject { QVERIFY(p.buildChannelLevel(QByteArray(), 1, 0.0).isEmpty()); QVERIFY(p.buildDcaMute(QByteArray(), 1, true).isEmpty()); } - - void gld_propertyFrames() { - GLDProbe p; - // GLD level + mute share op 0x16 (mute plane 0x90); DCA mute op 0x10 plane 0x10 - QCOMPARE(p.buildChannelLevel(H, 1, 0.0), - QByteArray::fromHex("f00000abcd0001160000028000f7")); - QCOMPARE(p.buildChannelMute(H, 1, true), QByteArray::fromHex("f00000abcd00011690000101f7")); - QCOMPARE(p.buildDcaMute(H, 1, true), QByteArray::fromHex("f00000abcd00011010000101f7")); - } - - void gld_dbTableDiffersFromAce() { - GLDProbe g; - AvantisProbe a; - // GLD uses reference FUN_00415780: positive .3 = 0x4E (ACE 0x4F) - QCOMPARE(g.encodeDb(5.3), QByteArray::fromHex("854e")); - QCOMPARE(a.encodeDb(5.3), QByteArray::fromHex("854f")); - // GLD negative .3 = 0xB3 - QCOMPARE(g.encodeDb(-5.3), QByteArray::fromHex("7ab3")); - } }; QTEST_MAIN(TestAllenHeathParsing) diff --git a/tests/test_mixer_capabilities.cpp b/tests/test_mixer_capabilities.cpp index 8085169..10e7338 100644 --- a/tests/test_mixer_capabilities.cpp +++ b/tests/test_mixer_capabilities.cpp @@ -13,7 +13,8 @@ class TestMixerCapabilities : public QObject { QCOMPARE(MixerCapabilities::forProtocolId("m32").dcaCount, 8); QCOMPARE(MixerCapabilities::forProtocolId("wing").dcaCount, 16); QCOMPARE(MixerCapabilities::forProtocolId("sq7").dcaCount, 8); - QCOMPARE(MixerCapabilities::forProtocolId("gld80").dcaCount, 8); + // GLD MIDI Protocol V1.4: DCA 1 to 16 = CH 10..1F + QCOMPARE(MixerCapabilities::forProtocolId("gld80").dcaCount, 16); QCOMPARE(MixerCapabilities::forProtocolId("avantis").dcaCount, 16); QCOMPARE(MixerCapabilities::forProtocolId("dlive").dcaCount, 16); QCOMPARE(MixerCapabilities::forProtocolId("tf5").dcaCount, 8); From 266fa2a36a68bd7f056b4d820af573ba5f71ee22 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Fri, 17 Jul 2026 00:33:12 -0400 Subject: [PATCH 10/11] feat(digico): drive SD/Quantum through the console's Generic OSC The SD driver spoke Yamaha DM7's MPRO/EEVT/MMIX protocol, pointed at Allen & Heath's port 51321, under a comment claiming it was reverse-engineered from a DiGiCo reference. It was not: the frames, the "TYPE:StageMix SYNCDIR:2" client registration and the object tree are all DM7's, and no DiGiCo console speaks them. It could never have worked. DiGiCo's actual network control is Generic OSC, and its shape is unusual: there is no published address map. The console's own feature is user-defined by design - the operator writes the messages, "*" stands for the channel - and the syntax varies by model and software version, with command sets handed out by DiGiCo support rather than documented. Inbound OSC only acts when External Control is enabled on the desk. So the driver is a template. It sends the patterns the operator entered and nothing for an operation they left blank, because an address we invented would look like it worked here and do nothing on the console. The patterns and the receive port live in MixerConfig, so they travel with the show, and the connection panel takes them for SD consoles. Capabilities follow: OSC over UDP, port 9000 (the common pairing, not a spec). The tests cover pattern expansion and, mainly, that an unconfigured operation stays silent. --- src/app/Application.cpp | 8 + src/core/Show.cpp | 12 +- src/core/Show.h | 10 + src/protocol/MixerCapabilities.cpp | 20 +- src/protocol/digico/DiGiCoProtocol.cpp | 335 ++++++------------------- src/protocol/digico/DiGiCoProtocol.h | 79 +++--- src/ui/ConnectionPanel.cpp | 40 +++ src/ui/ConnectionPanel.h | 7 + tests/CMakeLists.txt | 3 +- tests/test_digico_protocol.cpp | 98 ++++---- tests/test_show.cpp | 19 ++ 11 files changed, 260 insertions(+), 371 deletions(-) diff --git a/src/app/Application.cpp b/src/app/Application.cpp index 5f49a43..c0b9a5c 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -30,6 +30,7 @@ #include "protocol/allenheath/AllenHeathMidiProtocol.h" #include "protocol/allenheath/AllenHeathTcpProtocol.h" #include "protocol/allenheath/GLDProtocol.h" +#include "protocol/digico/DiGiCoProtocol.h" #include "protocol/discovery/ConsoleDiscoveryService.h" #include "protocol/discovery/DiscoveredConsole.h" #include "protocol/discovery/probes/AllenHeathProbeStrategy.h" @@ -388,6 +389,13 @@ void Application::connectToMixer(const QString& type, const QString& host, int p gld->setMidiChannel(m_show->mixerConfig().midiChannel); } + // DiGiCo has no published address map, so the console's own patterns come + // from the show rather than from us + if (auto* digico = dynamic_cast(m_mixer)) { + const MixerConfig cfg = m_show->mixerConfig(); + digico->setTemplates({cfg.oscChannelFader, cfg.oscChannelMute, cfg.oscSceneRecall}); + } + setupMixerConnection(type, host, port); } diff --git a/src/core/Show.cpp b/src/core/Show.cpp index e1e4b5d..4cdd4c6 100644 --- a/src/core/Show.cpp +++ b/src/core/Show.cpp @@ -13,6 +13,10 @@ QJsonObject MixerConfig::toJson() const { json["dcaCount"] = dcaCount; json["faderLaw"] = faderLaw; json["midiChannel"] = midiChannel; + json["oscChannelFader"] = oscChannelFader; + json["oscChannelMute"] = oscChannelMute; + json["oscSceneRecall"] = oscSceneRecall; + json["oscReceivePort"] = oscReceivePort; return json; } @@ -24,13 +28,19 @@ MixerConfig MixerConfig::fromJson(const QJsonObject& json) { config.dcaCount = json["dcaCount"].toInt(8); config.faderLaw = json["faderLaw"].toString("linear"); config.midiChannel = json["midiChannel"].toInt(1); + config.oscChannelFader = json["oscChannelFader"].toString(); + config.oscChannelMute = json["oscChannelMute"].toString(); + config.oscSceneRecall = json["oscSceneRecall"].toString(); + config.oscReceivePort = json["oscReceivePort"].toInt(8000); return config; } bool MixerConfig::operator==(const MixerConfig& other) const { return type == other.type && host == other.host && port == other.port && dcaCount == other.dcaCount && faderLaw == other.faderLaw && - midiChannel == other.midiChannel; + midiChannel == other.midiChannel && oscChannelFader == other.oscChannelFader && + oscChannelMute == other.oscChannelMute && oscSceneRecall == other.oscSceneRecall && + oscReceivePort == other.oscReceivePort; } Show::Show(QObject* parent) diff --git a/src/core/Show.h b/src/core/Show.h index 0294d97..d7b6536 100644 --- a/src/core/Show.h +++ b/src/core/Show.h @@ -39,6 +39,16 @@ struct MixerConfig { // has to match Setup / Control on the console. 1-16. int midiChannel = 1; + // DiGiCo publishes no OSC address map: its Generic OSC is user-defined by + // design, and the syntax varies by model and software version. So the + // operator supplies the patterns their desk speaks rather than us guessing. + // "*" stands for the channel, as in the console's own examples. An empty + // pattern means the console was not told how to do that, and we send nothing. + QString oscChannelFader; // e.g. "/ch/*/fader" + QString oscChannelMute; // e.g. "/ch/*/mute" + QString oscSceneRecall; // e.g. "/snapshot/fire" + int oscReceivePort = 8000; + [[nodiscard]] bool operator==(const MixerConfig& other) const; [[nodiscard]] bool operator!=(const MixerConfig& other) const { return !(*this == other); } diff --git a/src/protocol/MixerCapabilities.cpp b/src/protocol/MixerCapabilities.cpp index 47665b8..702ef8c 100644 --- a/src/protocol/MixerCapabilities.cpp +++ b/src/protocol/MixerCapabilities.cpp @@ -418,13 +418,15 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { caps.supportsEffectSends = true; break; - // DiGiCo SD series (TLV binary over TCP 51321, reverse-engineered) + // DiGiCo SD series over the console's Generic OSC (UDP). DiGiCo publishes no + // address map, so the driver takes the operator's patterns; see DiGiCoProtocol. + // Send/receive ports are paired by the operator, 9000/8000 being the common one. case ConsoleType::SD7: caps.manufacturer = Manufacturer::DiGiCo; - caps.protocol = ProtocolType::BinaryTcp; + caps.protocol = ProtocolType::OscUdp; caps.displayName = "DiGiCo SD7"; caps.protocolId = "sd7"; - caps.defaultPort = 51321; + caps.defaultPort = 9000; caps.dcaCount = 24; caps.inputChannels = 128; caps.mixBuses = 48; @@ -440,10 +442,10 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { case ConsoleType::SD9: caps.manufacturer = Manufacturer::DiGiCo; - caps.protocol = ProtocolType::BinaryTcp; + caps.protocol = ProtocolType::OscUdp; caps.displayName = "DiGiCo SD9"; caps.protocolId = "sd9"; - caps.defaultPort = 51321; + caps.defaultPort = 9000; caps.dcaCount = 12; caps.inputChannels = 96; caps.mixBuses = 48; @@ -459,10 +461,10 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { case ConsoleType::SD11: caps.manufacturer = Manufacturer::DiGiCo; - caps.protocol = ProtocolType::BinaryTcp; + caps.protocol = ProtocolType::OscUdp; caps.displayName = "DiGiCo SD11"; caps.protocolId = "sd11"; - caps.defaultPort = 51321; + caps.defaultPort = 9000; caps.dcaCount = 8; caps.inputChannels = 32; caps.mixBuses = 16; @@ -478,10 +480,10 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { case ConsoleType::SD12: caps.manufacturer = Manufacturer::DiGiCo; - caps.protocol = ProtocolType::BinaryTcp; + caps.protocol = ProtocolType::OscUdp; caps.displayName = "DiGiCo SD12"; caps.protocolId = "sd12"; - caps.defaultPort = 51321; + caps.defaultPort = 9000; caps.dcaCount = 12; caps.inputChannels = 72; caps.mixBuses = 36; diff --git a/src/protocol/digico/DiGiCoProtocol.cpp b/src/protocol/digico/DiGiCoProtocol.cpp index 63e6ad7..a82d156 100644 --- a/src/protocol/digico/DiGiCoProtocol.cpp +++ b/src/protocol/digico/DiGiCoProtocol.cpp @@ -1,141 +1,26 @@ #include "DiGiCoProtocol.h" #include "../../core/Cue.h" -#include -#include +#include "../../core/LevelDb.h" namespace OpenMix { -namespace { - -// MMIX "set" command opcode (fader/mute/level writes). -const QByteArray kOpcodeSet = QByteArray::fromHex("01110108"); -// MMIX subscription opcode (attach to the "Mixing" object tree). -const QByteArray kOpcodeSubscribe = QByteArray::fromHex("01010102"); -// EEVT client-registration opcode. Recovered strings are exact; the opcode word -// itself was not cleanly recoverable, so this is a best-effort value. -const QByteArray kOpcodeEvent = QByteArray::fromHex("01010102"); - -// centidB limits: fully-off fader sentinel and gain/EQ clamp seen on the wire. -constexpr qint32 kFaderOff = -15000; // -150.00 dB -constexpr qint32 kFaderMax = 1000; // +10.00 dB - -void appendBE32(QByteArray& out, quint32 value) { - out.append(static_cast((value >> 24) & 0xFF)); - out.append(static_cast((value >> 16) & 0xFF)); - out.append(static_cast((value >> 8) & 0xFF)); - out.append(static_cast(value & 0xFF)); -} - -// Two hex digits of the zero-based object index, as the reference formats it. -QString objectIndex(int oneBased) { - return QString("%1").arg(oneBased - 1, 2, 16, QChar('0')); -} - -} // namespace - DiGiCoProtocol::DiGiCoProtocol(const MixerCapabilities& caps, QObject* parent) : MixerProtocol(parent), m_capabilities(caps), m_transport(this) { - - m_port = caps.defaultPort; // 51321 - - QObject::connect(&m_transport, &TcpTransport::connected, this, - &DiGiCoProtocol::onTransportConnected); - QObject::connect(&m_transport, &TcpTransport::disconnected, this, - &DiGiCoProtocol::onTransportDisconnected); - QObject::connect(&m_transport, &TcpTransport::connectionError, this, - &DiGiCoProtocol::onTransportError); - QObject::connect(&m_transport, &TcpTransport::connectionLost, this, - &DiGiCoProtocol::onTransportConnectionLost); - QObject::connect(&m_transport, &TcpTransport::dataReceived, this, - &DiGiCoProtocol::onDataReceived); - QObject::connect(&m_transport, &TcpTransport::reconnecting, this, - &DiGiCoProtocol::onReconnecting); - - QObject::connect(&m_keepAliveTimer, &QTimer::timeout, this, - &DiGiCoProtocol::onKeepAliveTimeout); + m_port = caps.defaultPort; } DiGiCoProtocol::~DiGiCoProtocol() { disconnect(); } -// --- frame construction ---------------------------------------------------- - -QByteArray DiGiCoProtocol::buildFrame(const char* tag, const QByteArray& opcode, - const QByteArray& elements) { - QByteArray payload = opcode + elements; - - QByteArray afterLen; - afterLen.append('\x11'); // fixed marker - afterLen.append('\0'); - afterLen.append('\0'); - afterLen.append('\0'); - afterLen.append(static_cast(payload.size() & 0xFF)); // inner length - afterLen += payload; - - QByteArray frame; - frame.append(tag, 4); - appendBE32(frame, static_cast(afterLen.size())); - frame += afterLen; - return frame; -} - -QByteArray DiGiCoProtocol::stringElement(const QString& text) { - QByteArray utf8 = text.toUtf8(); - QByteArray el; - el.append('\x31'); // string type - appendBE32(el, static_cast(utf8.size() + 1)); // includes null terminator - el += utf8; - el.append('\0'); - return el; -} - -QByteArray DiGiCoProtocol::int32Element(qint32 value) { - QByteArray el; - el.append('\x14'); // int32 type - appendBE32(el, 4); - appendBE32(el, static_cast(value)); - return el; -} - -QByteArray DiGiCoProtocol::byteElement(quint8 value) { - QByteArray el; - el.append('\x01'); // byte/bool type - appendBE32(el, 1); - el.append(static_cast(value)); - return el; -} - -QByteArray DiGiCoProtocol::buildFaderMessage(const QString& objectPath, double db) { - qint32 centidB = static_cast(std::lround(db * 100.0)); - centidB = qBound(kFaderOff, centidB, kFaderMax); - return buildFrame("MMIX", kOpcodeSet, stringElement(objectPath) + int32Element(centidB)); -} - -QByteArray DiGiCoProtocol::buildMuteMessage(const QString& objectPath, bool muted) { - return buildFrame("MMIX", kOpcodeSet, - stringElement(objectPath) + byteElement(muted ? 1 : 0)); -} - -QByteArray DiGiCoProtocol::buildSubscribeMixing() { - QByteArray elements = stringElement("Mixing"); - elements.append('\x11'); // flag element type - appendBE32(elements, 1); - elements.append(static_cast(0x80)); - return buildFrame("MMIX", kOpcodeSubscribe, elements); -} - -QByteArray DiGiCoProtocol::buildSetClientType() { - return buildFrame("EEVT", kOpcodeEvent, - stringElement("SetClientType") + - stringElement("TYPE:StageMix SYNCDIR:2")); +QString DiGiCoProtocol::expand(const QString& pattern, int channel) { + if (pattern.isEmpty()) { + return {}; + } + QString address = pattern; + return address.replace(QLatin1Char('*'), QString::number(channel)); } -// --- connection ------------------------------------------------------------ - bool DiGiCoProtocol::connect(const QString& host, int port) { - if (m_connectionState == ConnectionState::Connected || - m_connectionState == ConnectionState::Connecting) { - disconnect(); - } + disconnect(); m_host = host; m_port = port; @@ -143,15 +28,26 @@ bool DiGiCoProtocol::connect(const QString& host, int port) { setConnectionState(ConnectionState::Connecting); setStatus(QString("Connecting to %1:%2...").arg(host).arg(port)); - return m_transport.connect(host, port); + if (!m_transport.connect(host, port)) { + setStatus("Failed to initialize transport"); + setConnectionState(ConnectionState::Disconnected); + emit connectionError("Failed to initialize transport"); + return false; + } + + // Generic OSC answers nothing on its own, so there is no handshake to wait + // for and no way to tell a listening console from a switched-off one. The + // link is "up" once the socket is, and the console only acts on any of this + // with External Control enabled. + setConnectionState(ConnectionState::Connected); + setStatus(QString("Sending to %1:%2 (no reply expected)").arg(host).arg(port)); + emit connected(); + return true; } void DiGiCoProtocol::disconnect() { - m_keepAliveTimer.stop(); m_transport.disconnect(); - m_parameterCache.clear(); - m_receiveBuffer.clear(); m_latencyMs = 0; setConnectionState(ConnectionState::Disconnected); @@ -159,160 +55,81 @@ void DiGiCoProtocol::disconnect() { emit disconnected(); } -void DiGiCoProtocol::sendHandshake() { - // Register as a control client, then subscribe to the mixing tree so the - // console streams fader/mute state back for tracking. - m_transport.send(buildSetClientType()); - m_transport.send(buildSubscribeMixing()); +void DiGiCoProtocol::setChannelFaderDb(int channel, double dB) { + const QString address = expand(m_templates.channelFader, channel); + if (!isConnected() || address.isEmpty()) { + return; // no pattern: the console was never told how to do this + } + m_transport.send(address, dB <= NEG_INF_DB ? -128.0f : static_cast(dB)); + m_parameterCache[address] = dB; } -// --- parameters ------------------------------------------------------------ - -void DiGiCoProtocol::sendParameter(const QString& path, const QVariant& value) { - if (m_connectionState != ConnectionState::Connected) +void DiGiCoProtocol::setChannelMute(int channel, bool muted) { + const QString address = expand(m_templates.channelMute, channel); + if (!isConnected() || address.isEmpty()) { return; - - QStringList parts = path.split('/', Qt::SkipEmptyParts); - if (parts.size() >= 3) { - const QString& kind = parts[0]; - bool ok = false; - int index = parts[1].toInt(&ok); - const QString& param = parts[2]; - - if (ok && index >= 1) { - QString object; - if (kind == "ch") - object = QString("Mixing/InputChannel%1").arg(objectIndex(index)); - else if (kind == "dca") - object = QString("Mixing/DCA%1").arg(objectIndex(index)); - else if (kind == "bus" || kind == "mix") - object = QString("Mixing/Mix%1").arg(objectIndex(index)); - - if (!object.isEmpty()) { - if (param == "fader") { - m_transport.send(buildFaderMessage(object + "/Fader/Level", value.toDouble())); - } else if (param == "mute") { - m_transport.send(buildMuteMessage(object + "/Mute", value.toBool())); - } - } - } } - - m_parameterCache[path] = value; -} - -QVariant DiGiCoProtocol::getParameter(const QString& path) { - return m_parameterCache.value(path); -} - -void DiGiCoProtocol::requestParameter(const QString& path) { - // state is pushed by the console after subscription, not polled - Q_UNUSED(path); + m_transport.send(address, muted ? 1 : 0); + m_parameterCache[address] = muted; } -void DiGiCoProtocol::requestParameterAsync(const QString& path, ParameterCallback callback) { - if (!callback) +void DiGiCoProtocol::recallScene(int sceneNumber) { + if (!isConnected() || m_templates.sceneRecall.isEmpty() || sceneNumber < 1) { return; - if (m_parameterCache.contains(path)) - callback(path, m_parameterCache[path], true); - else - callback(path, QVariant(), false); + } + // the scene number goes in a "*" if the pattern has one, and as the argument + // otherwise: consoles are configured both ways + const QString address = expand(m_templates.sceneRecall, sceneNumber); + if (m_templates.sceneRecall.contains(QLatin1Char('*'))) { + m_transport.send(address); + } else { + m_transport.send(address, sceneNumber); + } } -void DiGiCoProtocol::recallSnapshot(const Cue& cue) { - if (m_connectionState != ConnectionState::Connected) +void DiGiCoProtocol::sendParameter(const QString& path, const QVariant& value) { + if (!isConnected()) { return; - - QJsonObject params = cue.parameters(); - for (auto it = params.begin(); it != params.end(); ++it) { - sendParameter(it.key(), it.value().toVariant()); } -} -void DiGiCoProtocol::recallScene(int sceneNumber) { - if (m_connectionState != ConnectionState::Connected) + const QStringList parts = path.split('/', Qt::SkipEmptyParts); + if (parts.size() < 3 || parts[0] != "ch") { + return; + } + bool ok = false; + const int channel = parts[1].toInt(&ok); + if (!ok || channel < 1) { return; - // Snapshots recall by name over MSUP; scene-number recall is not exposed by - // this protocol, so this is a no-op placeholder. - Q_UNUSED(sceneNumber); -} - -void DiGiCoProtocol::refresh() { - if (m_connectionState == ConnectionState::Connected) - m_transport.send(buildSubscribeMixing()); -} - -// --- receive --------------------------------------------------------------- - -void DiGiCoProtocol::parseProtocolData(const QByteArray& data) { - m_receiveBuffer.append(data); - - // guard against a hostile/desynced peer: a frame larger than this is - // implausible for this protocol, so drop the buffer rather than grow it - constexpr qint64 kMaxFrame = 1 << 20; // 1 MiB - - // Walk complete frames: tag(4) + length(4, BE) + body(length). - while (m_receiveBuffer.size() >= 8) { - const quint32 length = qFromBigEndian( - reinterpret_cast(m_receiveBuffer.constData() + 4)); - const qint64 total = qint64(8) + length; // 64-bit: never wraps - - if (length > kMaxFrame) { - m_receiveBuffer.clear(); // lost frame sync; discard - break; - } - if (m_receiveBuffer.size() < total) - break; // wait for the rest of the frame - - QByteArray frame = m_receiveBuffer.left(static_cast(total)); - QByteArray tag = m_receiveBuffer.left(4); - QByteArray body = m_receiveBuffer.mid(8, static_cast(length)); - m_receiveBuffer.remove(0, static_cast(total)); - - // Keep the link alive by echoing the console's heartbeat frame. - if (tag == "EEVT" && body.contains("KeepAlive")) - m_transport.send(frame); } -} - -// --- transport slots ------------------------------------------------------- -void DiGiCoProtocol::onTransportConnected() { - setConnectionState(ConnectionState::Connected); - setStatus(QString("Connected to %1:%2").arg(m_host).arg(m_port)); - sendHandshake(); - m_keepAliveTimer.start(KEEPALIVE_INTERVAL); - emit connected(); + if (parts[2] == "fader") { + setChannelFaderDb(channel, value.toDouble()); + } else if (parts[2] == "mute") { + setChannelMute(channel, value.toBool()); + } } -void DiGiCoProtocol::onTransportDisconnected() { - m_keepAliveTimer.stop(); - setConnectionState(ConnectionState::Disconnected); - setStatus("Disconnected"); - emit disconnected(); -} +QVariant DiGiCoProtocol::getParameter(const QString& path) { return m_parameterCache.value(path); } -void DiGiCoProtocol::onTransportError(const QString& error) { - setStatus(error); - emit connectionError(error); -} +void DiGiCoProtocol::requestParameter(const QString& path) { Q_UNUSED(path); } -void DiGiCoProtocol::onTransportConnectionLost() { - setConnectionState(ConnectionState::Reconnecting); - setStatus("Connection lost, reconnecting..."); - emit connectionLost(); +void DiGiCoProtocol::requestParameterAsync(const QString& path, ParameterCallback callback) { + if (callback) { + callback(path, QVariant(), false); + } } -void DiGiCoProtocol::onDataReceived(const QByteArray& data) { parseProtocolData(data); } - -void DiGiCoProtocol::onKeepAliveTimeout() { - if (m_connectionState == ConnectionState::Connected) - m_transport.send(buildSubscribeMixing()); +void DiGiCoProtocol::recallSnapshot(const Cue& cue) { + if (!isConnected()) { + return; + } + const QJsonObject params = cue.parameters(); + for (auto it = params.begin(); it != params.end(); ++it) { + sendParameter(it.key(), it.value().toVariant()); + } } -void DiGiCoProtocol::onReconnecting(int attempt, int maxAttempts) { - setStatus(QString("Reconnecting (attempt %1/%2)...").arg(attempt).arg(maxAttempts)); -} +void DiGiCoProtocol::refresh() {} void DiGiCoProtocol::setStatus(const QString& status) { if (m_statusMessage != status) { diff --git a/src/protocol/digico/DiGiCoProtocol.h b/src/protocol/digico/DiGiCoProtocol.h index 872e823..67b886a 100644 --- a/src/protocol/digico/DiGiCoProtocol.h +++ b/src/protocol/digico/DiGiCoProtocol.h @@ -2,26 +2,24 @@ #include "../MixerCapabilities.h" #include "../MixerProtocol.h" -#include "../transport/TcpTransport.h" -#include +#include "../transport/OscTransport.h" #include -#include +#include namespace OpenMix { -// DiGiCo SD-series console driver. +// DiGiCo SD / Quantum series, over the console's Generic OSC. // -// Speaks the console's TLV control protocol over TCP port 51321. Frames carry a -// 4-character block tag (MMIX mixing, MSUP setup, MPRO property, EEVT events), -// a big-endian length, a fixed 0x11 marker, an inner-length byte, a 4-byte -// opcode, then a run of type/length/value elements (0x31 string, 0x14 int32, -// 0x11/0x01 byte). Channel indices are zero-based; fader/gain values are carried -// as centidB (dB x 100) in a big-endian signed int32. +// DiGiCo publishes no OSC address map. Generic OSC is user-defined by design: +// the operator writes the messages on the console, "*" stands for the channel +// number, and the syntax varies by console model and software version - DiGiCo +// hands out command sets through its support desk rather than a spec. Inbound +// OSC only does anything when External Control is enabled on the desk. // -// The wire format was reverse-engineered from a reference implementation and has -// not been validated against physical hardware. It is offered as a best-effort -// driver for the SD range; the framing, opcodes, and value scaling below reflect -// what could be recovered. +// So this driver is a template: it sends what the operator says their console +// listens for, and nothing at all for an operation they have not given a pattern +// for. Guessing an address here would look like it worked and silently do +// nothing on the desk. class DiGiCoProtocol : public MixerProtocol { Q_OBJECT @@ -31,7 +29,7 @@ class DiGiCoProtocol : public MixerProtocol { [[nodiscard]] QString protocolName() const override { return m_capabilities.displayName; } [[nodiscard]] QString protocolDescription() const override { - return m_capabilities.displayName + " SD Protocol"; + return m_capabilities.displayName + " Generic OSC"; } [[nodiscard]] bool connect(const QString& host, int port) override; @@ -50,54 +48,41 @@ class DiGiCoProtocol : public MixerProtocol { void recallSnapshot(const Cue& cue) override; void recallScene(int sceneNumber) override; + void setChannelFaderDb(int channel, double dB) override; + void setChannelMute(int channel, bool muted) override; + void refresh() override; [[nodiscard]] int latencyMs() const override { return m_latencyMs; } [[nodiscard]] const MixerCapabilities& capabilities() const override { return m_capabilities; } - // frame construction (exposed for tests) - static QByteArray buildFrame(const char* tag, const QByteArray& opcode, - const QByteArray& elements); - static QByteArray stringElement(const QString& text); - static QByteArray int32Element(qint32 value); - static QByteArray byteElement(quint8 value); - - // semantic setters (exposed for tests) - QByteArray buildFaderMessage(const QString& objectPath, double db); - QByteArray buildMuteMessage(const QString& objectPath, bool muted); - QByteArray buildSubscribeMixing(); - QByteArray buildSetClientType(); - - protected: - virtual void parseProtocolData(const QByteArray& data); - - private slots: - void onTransportConnected(); - void onTransportDisconnected(); - void onTransportError(const QString& error); - void onTransportConnectionLost(); - void onDataReceived(const QByteArray& data); - void onKeepAliveTimeout(); - void onReconnecting(int attempt, int maxAttempts); + // The addresses this console listens on, as the operator entered them. "*" in + // a pattern is replaced with the channel number. An empty pattern disables + // that operation outright. + struct OscTemplates { + QString channelFader; + QString channelMute; + QString sceneRecall; + }; + void setTemplates(const OscTemplates& templates) { m_templates = templates; } + [[nodiscard]] const OscTemplates& templates() const { return m_templates; } + + // "/ch/*/fader" + channel 3 -> "/ch/3/fader"; empty in, empty out + static QString expand(const QString& pattern, int channel); private: void setStatus(const QString& status); void setConnectionState(ConnectionState state); - void sendHandshake(); MixerCapabilities m_capabilities; - TcpTransport m_transport; - QMap m_parameterCache; + OscTransport m_transport; + OscTemplates m_templates; QString m_host; int m_port = 0; ConnectionState m_connectionState = ConnectionState::Disconnected; QString m_statusMessage; - - QTimer m_keepAliveTimer; - static constexpr int KEEPALIVE_INTERVAL = 5000; - int m_latencyMs = 0; - QByteArray m_receiveBuffer; + QMap m_parameterCache; }; } // namespace OpenMix diff --git a/src/ui/ConnectionPanel.cpp b/src/ui/ConnectionPanel.cpp index c900b24..131a07e 100644 --- a/src/ui/ConnectionPanel.cpp +++ b/src/ui/ConnectionPanel.cpp @@ -117,6 +117,31 @@ void ConnectionPanel::setupUi() { m_midiChannelSpin->setToolTip(tr("Must match the console: Setup / Control / MIDI channel.")); formLayout->addRow(m_midiChannelLabel, m_midiChannelSpin); + // DiGiCo publishes no OSC address map, so the operator supplies the patterns + // their console listens for + m_oscHintLabel = new QLabel( + tr("DiGiCo does not publish its OSC addresses. Enter the patterns your console\n" + "listens for (\"*\" = channel number), and enable External Control on the desk."), + this); + m_oscHintLabel->setStyleSheet("color: gray; font-style: italic;"); + m_oscHintLabel->setWordWrap(true); + formLayout->addRow(QString(), m_oscHintLabel); + + m_oscFaderLabel = new QLabel(tr("OSC Fader:"), this); + m_oscFaderEdit = new QLineEdit(this); + m_oscFaderEdit->setPlaceholderText(tr("/ch/*/fader")); + formLayout->addRow(m_oscFaderLabel, m_oscFaderEdit); + + m_oscMuteLabel = new QLabel(tr("OSC Mute:"), this); + m_oscMuteEdit = new QLineEdit(this); + m_oscMuteEdit->setPlaceholderText(tr("/ch/*/mute")); + formLayout->addRow(m_oscMuteLabel, m_oscMuteEdit); + + m_oscSceneLabel = new QLabel(tr("OSC Scene Recall:"), this); + m_oscSceneEdit = new QLineEdit(this); + m_oscSceneEdit->setPlaceholderText(tr("/snapshot/fire")); + formLayout->addRow(m_oscSceneLabel, m_oscSceneEdit); + m_loopbackLabel = new QLabel(tr("No hardware connection required."), this); m_loopbackLabel->setStyleSheet("color: gray; font-style: italic;"); m_loopbackLabel->setVisible(false); @@ -376,6 +401,15 @@ void ConnectionPanel::onProtocolTypeChanged(int index) { m_midiChannelLabel->setVisible(hasMidiChannel); m_midiChannelSpin->setVisible(hasMidiChannel); + const bool hasOscTemplates = type.startsWith("sd"); + m_oscHintLabel->setVisible(hasOscTemplates); + m_oscFaderLabel->setVisible(hasOscTemplates); + m_oscFaderEdit->setVisible(hasOscTemplates); + m_oscMuteLabel->setVisible(hasOscTemplates); + m_oscMuteEdit->setVisible(hasOscTemplates); + m_oscSceneLabel->setVisible(hasOscTemplates); + m_oscSceneEdit->setVisible(hasOscTemplates); + if (!isLoopback) { m_portEdit->setText(QString::number(caps.defaultPort)); } @@ -459,6 +493,9 @@ void ConnectionPanel::loadFromConfig() { const int lawIdx = m_faderLawCombo->findData(config.faderLaw); m_faderLawCombo->setCurrentIndex(lawIdx < 0 ? 0 : lawIdx); m_midiChannelSpin->setValue(config.midiChannel); + m_oscFaderEdit->setText(config.oscChannelFader); + m_oscMuteEdit->setText(config.oscChannelMute); + m_oscSceneEdit->setText(config.oscSceneRecall); } void ConnectionPanel::saveToConfig() { @@ -469,6 +506,9 @@ void ConnectionPanel::saveToConfig() { config.dcaCount = MixerCapabilities::forProtocolId(config.type).dcaCount; config.faderLaw = m_faderLawCombo->currentData().toString(); config.midiChannel = m_midiChannelSpin->value(); + config.oscChannelFader = m_oscFaderEdit->text().trimmed(); + config.oscChannelMute = m_oscMuteEdit->text().trimmed(); + config.oscSceneRecall = m_oscSceneEdit->text().trimmed(); m_app->show()->setMixerConfig(config); } diff --git a/src/ui/ConnectionPanel.h b/src/ui/ConnectionPanel.h index db8f680..c8a6eac 100644 --- a/src/ui/ConnectionPanel.h +++ b/src/ui/ConnectionPanel.h @@ -57,6 +57,13 @@ class ConnectionPanel : public QWidget { QLabel* m_faderLawLabel; QSpinBox* m_midiChannelSpin; QLabel* m_midiChannelLabel; + QLineEdit* m_oscFaderEdit; + QLineEdit* m_oscMuteEdit; + QLineEdit* m_oscSceneEdit; + QLabel* m_oscFaderLabel; + QLabel* m_oscMuteLabel; + QLabel* m_oscSceneLabel; + QLabel* m_oscHintLabel; QLabel* m_loopbackLabel; QPushButton* m_connectButton; QPushButton* m_disconnectButton; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1f02451..b5db143 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -301,7 +301,7 @@ add_test(NAME DeletePlayingCueTest COMMAND test_delete_playing_cue) add_executable(test_digico_protocol test_digico_protocol.cpp ${CMAKE_SOURCE_DIR}/src/protocol/digico/DiGiCoProtocol.cpp - ${CMAKE_SOURCE_DIR}/src/protocol/transport/TcpTransport.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/transport/OscTransport.cpp ${CMAKE_SOURCE_DIR}/src/protocol/MixerProtocol.cpp ${CMAKE_SOURCE_DIR}/src/protocol/MixerCapabilities.cpp ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp @@ -312,6 +312,7 @@ target_link_libraries(test_digico_protocol PRIVATE Qt6::Core Qt6::Network Qt6::Test + openmix_liblo ) target_include_directories(test_digico_protocol PRIVATE ${CMAKE_SOURCE_DIR}/src diff --git a/tests/test_digico_protocol.cpp b/tests/test_digico_protocol.cpp index 83aa1cd..91496bb 100644 --- a/tests/test_digico_protocol.cpp +++ b/tests/test_digico_protocol.cpp @@ -1,77 +1,67 @@ #include "protocol/MixerCapabilities.h" #include "protocol/digico/DiGiCoProtocol.h" -#include #include using namespace OpenMix; +// DiGiCo publishes no OSC address map: Generic OSC is user-defined, and the +// syntax varies by model and software version. The driver therefore sends the +// operator's patterns and nothing else, so what is worth testing is that a +// pattern is expanded exactly as given and that a missing one sends nothing at +// all - a guessed address would look like it worked and do nothing on the desk. class TestDiGiCoProtocol : public QObject { Q_OBJECT private slots: - void frameHasTagLengthAndMarker() { - QByteArray frame = DiGiCoProtocol::buildFrame( - "MMIX", QByteArray::fromHex("01110108"), DiGiCoProtocol::int32Element(0)); - - QCOMPARE(frame.left(4), QByteArray("MMIX")); - - // length field covers everything after it; marker 0x11 follows. - quint32 len = qFromBigEndian( - reinterpret_cast(frame.constData() + 4)); - QCOMPARE(static_cast(len), frame.size() - 8); - QCOMPARE(static_cast(frame[8]), quint8(0x11)); + void expand_putsTheChannelWhereTheStarIs() { + // "*" is the console's own convention for the channel number + QCOMPARE(DiGiCoProtocol::expand("/ch/*/fader", 3), QString("/ch/3/fader")); + QCOMPARE(DiGiCoProtocol::expand("/MyDevice/MyParameter/*", 12), + QString("/MyDevice/MyParameter/12")); + // a pattern without a star is an address in its own right + QCOMPARE(DiGiCoProtocol::expand("/snapshot/fire", 4), QString("/snapshot/fire")); } - void stringElementCarriesNullTerminatedText() { - QByteArray el = DiGiCoProtocol::stringElement("Mixing"); - QCOMPARE(static_cast(el[0]), quint8(0x31)); - quint32 len = qFromBigEndian( - reinterpret_cast(el.constData() + 1)); - QCOMPARE(static_cast(len), 7); // "Mixing" + NUL - QCOMPARE(el.mid(5), QByteArray("Mixing\0", 7)); + void expand_withoutAPatternYieldsNothing() { + // an operation the operator never configured has no address to send to + QVERIFY(DiGiCoProtocol::expand(QString(), 1).isEmpty()); + QVERIFY(DiGiCoProtocol::expand("", 7).isEmpty()); } - void faderScalesToCentidBBigEndian() { - DiGiCoProtocol proto(MixerCapabilities::forConsole(ConsoleType::SD12)); - // +10 dB -> 1000 centidB, trailing signed int32 big-endian. - QByteArray frame = proto.buildFaderMessage("Mixing/InputChannel00/Fader/Level", 10.0); - QCOMPARE(frame.right(4), QByteArray::fromHex("000003e8")); - QVERIFY(frame.contains("Mixing/InputChannel00/Fader/Level")); + void templates_defaultToUnconfigured() { + // nothing is assumed about the console's syntax until the operator says + DiGiCoProtocol p(MixerCapabilities::forConsole(ConsoleType::SD12)); + QVERIFY(p.templates().channelFader.isEmpty()); + QVERIFY(p.templates().channelMute.isEmpty()); + QVERIFY(p.templates().sceneRecall.isEmpty()); } - void faderClampsToOffSentinel() { - DiGiCoProtocol proto(MixerCapabilities::forConsole(ConsoleType::SD12)); - // Below -150 dB clamps to the -15000 centidB off-sentinel (0xFFFFC568). - QByteArray frame = proto.buildFaderMessage("Mixing/InputChannel00/Fader/Level", -200.0); - QCOMPARE(frame.right(4), QByteArray::fromHex("ffffc568")); + void templates_roundTrip() { + DiGiCoProtocol p(MixerCapabilities::forConsole(ConsoleType::SD12)); + p.setTemplates({"/ch/*/fader", "/ch/*/mute", "/snapshot/fire"}); + QCOMPARE(p.templates().channelFader, QString("/ch/*/fader")); + QCOMPARE(p.templates().channelMute, QString("/ch/*/mute")); + QCOMPARE(p.templates().sceneRecall, QString("/snapshot/fire")); } - void muteEncodesStateByte() { - DiGiCoProtocol proto(MixerCapabilities::forConsole(ConsoleType::SD12)); - QCOMPARE(proto.buildMuteMessage("Mixing/InputChannel00/Mute", true).right(1), - QByteArray::fromHex("01")); - QCOMPARE(proto.buildMuteMessage("Mixing/InputChannel00/Mute", false).right(1), - QByteArray::fromHex("00")); + void unconfigured_sendsNothingRatherThanGuessing() { + // not connected and not configured: the calls must be inert, not invent + DiGiCoProtocol p(MixerCapabilities::forConsole(ConsoleType::SD12)); + p.setChannelFaderDb(1, 0.0); + p.setChannelMute(1, true); + p.recallScene(1); + QVERIFY(!p.isConnected()); + QVERIFY(p.getParameter("/ch/1/fader").isNull()); } - void subscribeMixingTargetsMixingTree() { - DiGiCoProtocol proto(MixerCapabilities::forConsole(ConsoleType::SD12)); - QByteArray frame = proto.buildSubscribeMixing(); - QCOMPARE(frame.left(4), QByteArray("MMIX")); - QVERIFY(frame.contains("Mixing")); - QCOMPARE(frame.right(1), QByteArray::fromHex("80")); // subscription flag - } - - void capabilitiesRegistered() { - MixerCapabilities caps = MixerCapabilities::forProtocolId("sd12"); - QCOMPARE(caps.protocolId, QString("sd12")); - QCOMPARE(caps.defaultPort, 51321); - QCOMPARE(caps.manufacturerName(), QString("DiGiCo")); - QVERIFY(caps.isSupported()); - QCOMPARE(caps.displayName, QString("DiGiCo SD12")); - - DiGiCoProtocol proto(caps); - QCOMPARE(proto.capabilities().defaultPort, 51321); + void capabilities_areGenericOsc() { + // Generic OSC is UDP, and the port is whatever the operator paired; 9000 + // is only the common default. It is not Allen & Heath's 51321, which this + // driver used to point at while speaking Yamaha's protocol. + const auto caps = MixerCapabilities::forConsole(ConsoleType::SD12); + QCOMPARE(caps.protocol, ProtocolType::OscUdp); + QCOMPARE(caps.defaultPort, 9000); + QCOMPARE(caps.manufacturer, Manufacturer::DiGiCo); } }; diff --git a/tests/test_show.cpp b/tests/test_show.cpp index d1b70a4..ed92295 100644 --- a/tests/test_show.cpp +++ b/tests/test_show.cpp @@ -77,6 +77,25 @@ class TestShow : public QObject { QCOMPARE(MixerConfig::fromJson(legacy).faderLaw, QString("linear")); } + void mixerConfig_oscTemplatesRoundTrip() { + // DiGiCo's OSC addresses are the operator's to supply, so the show carries + // them; a show that has never been configured carries none + MixerConfig config; + config.type = "sd12"; + config.oscChannelFader = "/ch/*/fader"; + config.oscSceneRecall = "/snapshot/fire"; + + const MixerConfig back = MixerConfig::fromJson(config.toJson()); + QCOMPARE(back.oscChannelFader, QString("/ch/*/fader")); + QCOMPARE(back.oscSceneRecall, QString("/snapshot/fire")); + QVERIFY(back.oscChannelMute.isEmpty()); + QVERIFY(back == config); + + QJsonObject legacy; + legacy["type"] = "sd12"; + QVERIFY(MixerConfig::fromJson(legacy).oscChannelFader.isEmpty()); + } + void setMixerConfig_identicalConfig_isNoOp() { Show show; MixerConfig config = show.mixerConfig(); From e9a666fb754027d2bf246b240aa7f33a48d54f44 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Fri, 17 Jul 2026 00:54:32 -0400 Subject: [PATCH 11/11] docs: trim protocol comments to repo style Comment-only. The rationale for levels being dB was repeated across LevelDb.h, Cue.h, Show.h and three driver headers; it now lives once in LevelDb.h and the rest point there. Class headers come back to the length of the ones around them, and a few comments that described the edit rather than the code lost that framing. Byte layouts, doc provenance and the notes about console settings that cannot be read back all stay: none of them are visible in the code. One was wrong rather than just long: GLDProtocol said names longer than the limit were "the console's to reject, not ours to send", while buildName() truncates with left(). It now states what the protocol doc says - 8 accepted, 5 displayed. --- src/core/Cue.cpp | 7 +++---- src/core/Cue.h | 13 +++++-------- src/core/LevelDb.h | 19 ++++++++----------- src/core/Show.h | 8 +++----- .../allenheath/AllenHeathMidiProtocol.h | 8 +++----- .../allenheath/AllenHeathTcpProtocol.h | 7 +++---- src/protocol/allenheath/GLDProtocol.h | 13 +++++-------- src/protocol/allenheath/QuProtocol.h | 13 ++++++------- src/protocol/behringer/X32Protocol.cpp | 5 ++--- src/protocol/behringer/X32Protocol.h | 5 ++--- src/protocol/digico/DiGiCoProtocol.h | 15 +++++---------- tests/test_allenheath_session.cpp | 7 +++---- tests/test_cue.cpp | 6 +++--- tests/test_digico_protocol.cpp | 11 ++++------- 14 files changed, 55 insertions(+), 82 deletions(-) diff --git a/src/core/Cue.cpp b/src/core/Cue.cpp index f749321..327ed80 100644 --- a/src/core/Cue.cpp +++ b/src/core/Cue.cpp @@ -582,10 +582,9 @@ Cue Cue::fromJson(const QJsonObject& json) { } } - // per-channel level overrides. Shows written before levels moved to dB carry - // 0..1 positions under the old key; convert them through the law that was in - // force then, which is the best that can be done - what a position meant in dB - // depended on the console it was played back on. + // per-channel level overrides. Shows that store 0..1 positions carry them + // under the old key; converting them is the best that can be done, since what + // a position meant in dB depended on the console it played back on. if (json.contains("channelLevelsDb")) { const QJsonObject levelsObj = json["channelLevelsDb"].toObject(); for (auto it = levelsObj.constBegin(); it != levelsObj.constEnd(); ++it) { diff --git a/src/core/Cue.h b/src/core/Cue.h index f442b56..4813140 100644 --- a/src/core/Cue.h +++ b/src/core/Cue.h @@ -190,11 +190,8 @@ class Cue { void setChannelProfile(int channel, const QString& slot) { m_channelProfiles[channel] = slot; } void removeChannelProfile(int channel) { m_channelProfiles.remove(channel); } - // Per-channel fader level override, in dB (channel -> dB), as the console - // itself expresses levels. Storing dB rather than a 0..1 fader position keeps - // a cue meaning the same thing on every console: a position only becomes a - // level once a console-specific fader law is applied, so the same show file - // would otherwise play back at different levels per desk. + // per-channel fader level override in dB, so a cue means the same thing on + // every console (see LevelDb.h) [[nodiscard]] QMap channelLevels() const { return m_channelLevels; } void setChannelLevels(const QMap& levels) { m_channelLevels = levels; } void setChannelLevel(int channel, double dB) { m_channelLevels[channel] = dB; } @@ -203,9 +200,9 @@ class Cue { static constexpr double NEG_INF_DB = OpenMix::NEG_INF_DB; static constexpr double MAX_DB = OpenMix::MAX_DB; - // A 0..1 fader position under the law OpenMix applied before cues stored dB: - // unity at 3/4 travel, +10 dB at the top, -60 dB at the bottom of the useful - // throw. Only used to read shows written before the change. + // The law behind a 0..1 fader position: unity at 3/4 travel, +10 dB at the + // top, -60 dB at the bottom of the useful throw. Reads shows that store + // positions rather than dB. static double dbFromLegacyPosition(double position); // per-FX-unit mute state (fx unit index -> muted). Sent to the console on fire. diff --git a/src/core/LevelDb.h b/src/core/LevelDb.h index baa8852..a8235af 100644 --- a/src/core/LevelDb.h +++ b/src/core/LevelDb.h @@ -2,22 +2,19 @@ namespace OpenMix { -// Levels are carried in dB throughout OpenMix - in cues, across the protocol API, -// and into each driver, which encodes dB the way its console wants. A 0..1 fader -// position is not a level until a console-specific fader law is applied, so a -// position would make the same cue play back differently on different desks. -// -// dB below this read as -inf (fader fully down). JSON cannot carry an infinity -// and every console has its own -inf encoding, so the sentinel is a plain number -// below any console's floor. -999 and the +10 ceiling are the reference -// implementation's own values, which its Allen & Heath drivers clamp levels to. +// Levels are dB everywhere: in cues, across the protocol API, and into each +// driver, which encodes dB the way its console wants. A 0..1 position is not a +// level until a console applies its fader law, so the same cue would play back +// differently per desk. + +// dB at or below this is -inf. JSON cannot carry an infinity and each console +// encodes -inf its own way, so the sentinel is a plain number below every floor. inline constexpr double NEG_INF_DB = -999.0; // the top of the throw on every console OpenMix speaks to inline constexpr double MAX_DB = 10.0; -// the reference clamps levels to this floor before encoding, and treats anything -// under it as -inf +// the floor levels are clamped to before encoding; under it is -inf inline constexpr double MIN_DB = -95.0; } // namespace OpenMix diff --git a/src/core/Show.h b/src/core/Show.h index d7b6536..e752c66 100644 --- a/src/core/Show.h +++ b/src/core/Show.h @@ -39,11 +39,9 @@ struct MixerConfig { // has to match Setup / Control on the console. 1-16. int midiChannel = 1; - // DiGiCo publishes no OSC address map: its Generic OSC is user-defined by - // design, and the syntax varies by model and software version. So the - // operator supplies the patterns their desk speaks rather than us guessing. - // "*" stands for the channel, as in the console's own examples. An empty - // pattern means the console was not told how to do that, and we send nothing. + // DiGiCo's OSC addresses are the operator's to supply: the syntax varies by + // model and software version. "*" stands for the channel; an empty pattern + // disables that operation (see DiGiCoProtocol). QString oscChannelFader; // e.g. "/ch/*/fader" QString oscChannelMute; // e.g. "/ch/*/mute" QString oscSceneRecall; // e.g. "/snapshot/fire" diff --git a/src/protocol/allenheath/AllenHeathMidiProtocol.h b/src/protocol/allenheath/AllenHeathMidiProtocol.h index e8b5b59..2180b8c 100644 --- a/src/protocol/allenheath/AllenHeathMidiProtocol.h +++ b/src/protocol/allenheath/AllenHeathMidiProtocol.h @@ -79,11 +79,9 @@ class AllenHeathMidiProtocol : public MixerProtocol { void setChannelFaderDb(int channel, double dB) override; void setChannelMute(int channel, bool muted) override; - // Which curve the console maps NRPN levels through. This is a console-side - // setting (Utility > General > MIDI > NRPN Fader Law) that cannot be read back - // over MIDI, so the app has to be told which one the desk is in; a mismatch - // still moves the fader, just to the wrong dB. Linear Taper is the console's - // standard mode. + // Which curve the console maps NRPN levels through, set at Utility > General > + // MIDI > NRPN Fader Law and not readable over MIDI, so it has to be told: a + // mismatch still moves the fader, just to the wrong dB. Linear is standard. enum class FaderLaw { LinearTaper, AudioTaper }; void setFaderLaw(FaderLaw law) { m_faderLaw = law; } [[nodiscard]] FaderLaw faderLaw() const { return m_faderLaw; } diff --git a/src/protocol/allenheath/AllenHeathTcpProtocol.h b/src/protocol/allenheath/AllenHeathTcpProtocol.h index 3c6b864..1f9008e 100644 --- a/src/protocol/allenheath/AllenHeathTcpProtocol.h +++ b/src/protocol/allenheath/AllenHeathTcpProtocol.h @@ -19,10 +19,10 @@ namespace OpenMix { // and scene recall (the DCA *fader* plane is receive-only, as in the reference). // Byte layouts recovered from the reference AvantisDriver/DLiveDriver. // -// A session needs two sockets. The client advertises its UDP local port +// A session needs two sockets: the client advertises its UDP local port // (E0 00 04 01 03 E7), the console answers with its own // (E0 00 04 02 03 E7), and only then does the subscribe chain start. -// Metering arrives on the UDP socket and the keep-alive goes back out over it. +// Metering arrives on that UDP socket and the keep-alive goes back out over it. class AllenHeathTcpProtocol : public MixerProtocol { Q_OBJECT @@ -123,8 +123,7 @@ class AllenHeathTcpProtocol : public MixerProtocol { // A meter datagram carries one 8-byte record per channel, in channel order; // only the record's first byte is a level. 0 is -inf and 0xFE/0xFF mark a // channel the console is not metering, so both read as silence. The byte's dB - // curve is unknown: it is passed through as a monotonic 0..1 position, not a - // calibrated level. + // curve is unknown, so it passes through as a monotonic position. static constexpr int METER_RECORD_SIZE = 8; static constexpr int METER_CHANNELS = 128; static constexpr quint8 METER_INVALID = 0xFE; diff --git a/src/protocol/allenheath/GLDProtocol.h b/src/protocol/allenheath/GLDProtocol.h index c1da2a0..9f02cc0 100644 --- a/src/protocol/allenheath/GLDProtocol.h +++ b/src/protocol/allenheath/GLDProtocol.h @@ -4,12 +4,10 @@ namespace OpenMix { -// Allen & Heath GLD series (GLD-80, GLD-112), MIDI over TCP 51325. -// -// Verified against the GLD MIDI and TCP/IP Protocol V1.4. GLD sits between SQ -// and Qu: like Qu it puts the channel in the NRPN MSB and mutes with Note On, -// but its fader NRPN carries no data-entry LSB, and its level table is its own -// (0 dB = 0x6B, where Qu's is 0x62). Names and colours are SysEx, not NRPN. +// Allen & Heath GLD series (GLD-80, GLD-112), MIDI over TCP 51325, per the GLD +// MIDI and TCP/IP Protocol V1.4. Like Qu, the channel goes in the NRPN MSB and +// mutes are Note On, but the fader NRPN carries no data-entry LSB and the level +// table is GLD's own. Names and colours are SysEx. // // The console's MIDI channel (Setup / Control) has to match the one used here: // it appears in every message and cannot be read back. @@ -41,11 +39,10 @@ class GLDProtocol : public AllenHeathMidiProtocol { // NRPN parameter ids static constexpr int ID_FADER = 0x17; - // the console's colour palette, in its own order static constexpr int COLOUR_OFF = 0x00; static constexpr int COLOUR_MAX = 0x07; - // names longer than this are the console's to reject, not ours to send + // the console takes 8 characters and shows 5 of them on the strip static constexpr int MAX_NAME_LENGTH = 8; QByteArray buildFader(int channelId, double dB) const; diff --git a/src/protocol/allenheath/QuProtocol.h b/src/protocol/allenheath/QuProtocol.h index 2fa2712..30b4cf0 100644 --- a/src/protocol/allenheath/QuProtocol.h +++ b/src/protocol/allenheath/QuProtocol.h @@ -5,14 +5,13 @@ namespace OpenMix { -// Allen & Heath Qu series (Qu-16, Qu-24, Qu-32) protocol, MIDI over TCP 51325. -// All Qu models run the same 32-channel DSP, differing only in fader count. +// Allen & Heath Qu series (Qu-16, Qu-24, Qu-32), MIDI over TCP 51325, per the Qu +// Mixer MIDI Protocol V1.9+. All Qu models run the same 32-channel DSP, differing +// only in fader count. // -// Qu is not SQ with different numbers: the two NRPN halves are swapped. Qu puts -// the channel in the MSB and the parameter id in the LSB (BN 63 CH, BN 62 ID, -// BN 06 VA, BN 26 VX), levels are 7-bit, and mutes are Note On/Off rather than an -// NRPN. Verified against the Qu Mixer MIDI Protocol V1.9+ (Oct 2021), which -// covers Qu-16/24/32 alongside Qu-Pac and Qu-SB. +// Qu is not SQ with different numbers: the NRPN halves are swapped. Qu puts the +// channel in the MSB and the parameter id in the LSB (BN 63 CH, BN 62 ID, +// BN 06 VA, BN 26 VX), levels are 7-bit, and mutes are Note On/Off. class QuProtocol : public AllenHeathMidiProtocol { Q_OBJECT diff --git a/src/protocol/behringer/X32Protocol.cpp b/src/protocol/behringer/X32Protocol.cpp index 6ae5a0f..49ab6eb 100644 --- a/src/protocol/behringer/X32Protocol.cpp +++ b/src/protocol/behringer/X32Protocol.cpp @@ -25,9 +25,8 @@ QString x32Bus(int bus) { return QString("/bus/%1").arg(bus, 2, 10, QChar('0')); float clampUnit(double v) { return static_cast(std::clamp(v, 0.0, 1.0)); } -// X32 faders are the one wire format in OpenMix that is a position rather than a -// level: the console takes a 0..1 float and applies its own piecewise law. The -// segments are the published law (X32 OSC Remote Protocol, "level(float)"): +// X32 faders are a position, not a level: the console takes a 0..1 float and +// applies its own piecewise law (X32 OSC Remote Protocol, "level(float)"): // 0..0.0625 = -oo/-90..-60 dB, 0.0625..0.25 = -60..-30, // 0.25..0.5 = -30..-10, 0.5..1.0 = -10..+10. float x32FaderFromDb(double dB) { diff --git a/src/protocol/behringer/X32Protocol.h b/src/protocol/behringer/X32Protocol.h index 5f5db92..11dc463 100644 --- a/src/protocol/behringer/X32Protocol.h +++ b/src/protocol/behringer/X32Protocol.h @@ -129,9 +129,8 @@ class X32Protocol : public MixerProtocol { ConnectionState m_connectionState = ConnectionState::Disconnected; QString m_statusMessage; - // The console stops sending updates ~10 s after the last /xremote, so renew at - // 4.5 s (as the reference does): a single dropped renew still leaves a further - // one inside the window, where an 8 s period would not. + // the console stops sending updates ~10 s after the last /xremote, so renew + // often enough that a dropped one still leaves another inside the window QTimer m_keepAliveTimer; static constexpr int KEEPALIVE_INTERVAL = 4500; diff --git a/src/protocol/digico/DiGiCoProtocol.h b/src/protocol/digico/DiGiCoProtocol.h index 67b886a..78a24b0 100644 --- a/src/protocol/digico/DiGiCoProtocol.h +++ b/src/protocol/digico/DiGiCoProtocol.h @@ -10,16 +10,11 @@ namespace OpenMix { // DiGiCo SD / Quantum series, over the console's Generic OSC. // -// DiGiCo publishes no OSC address map. Generic OSC is user-defined by design: -// the operator writes the messages on the console, "*" stands for the channel -// number, and the syntax varies by console model and software version - DiGiCo -// hands out command sets through its support desk rather than a spec. Inbound -// OSC only does anything when External Control is enabled on the desk. -// -// So this driver is a template: it sends what the operator says their console -// listens for, and nothing at all for an operation they have not given a pattern -// for. Guessing an address here would look like it worked and silently do -// nothing on the desk. +// DiGiCo publishes no address map: Generic OSC is user-defined, the syntax varies +// by model and software version, and the console only acts on it with External +// Control enabled. So the driver is a template - it sends the operator's patterns +// and nothing for an operation they left blank, since a guessed address would +// look like it worked here and do nothing on the desk. class DiGiCoProtocol : public MixerProtocol { Q_OBJECT diff --git a/tests/test_allenheath_session.cpp b/tests/test_allenheath_session.cpp index b375fe2..794d5da 100644 --- a/tests/test_allenheath_session.cpp +++ b/tests/test_allenheath_session.cpp @@ -9,10 +9,9 @@ using namespace OpenMix; namespace { -// Stands in for a dLive MixRack's ACE control socket, replaying the reference's -// side of the session: stay silent until the client advertises its UDP port with -// E0 00 04 01 03 E7, answer with our own port, then hand back a 2-byte -// handle per subscribe (one at a time, as the console does). +// Stands in for a dLive MixRack's ACE control socket: silent until the client +// advertises its UDP port with E0 00 04 01 03 E7, then answers with its +// own and hands back one 2-byte handle per subscribe. class StubMixRack : public QObject { Q_OBJECT diff --git a/tests/test_cue.cpp b/tests/test_cue.cpp index e8bfa2f..e295c48 100644 --- a/tests/test_cue.cpp +++ b/tests/test_cue.cpp @@ -265,13 +265,13 @@ class TestCue : public QObject { } void channelLevels_legacyPositionsBecomeDb() { - // shows written before levels moved to dB carry 0..1 positions under the - // old key, and must still load + // shows that store 0..1 positions carry them under the old key, and must + // still load QJsonObject json; json["number"] = 1.0; json["type"] = "snapshot"; QJsonObject legacy; - legacy["1"] = 0.75; // unity under the old law + legacy["1"] = 0.75; // unity under the position law legacy["2"] = 1.0; // top of throw legacy["3"] = 0.0; // fully down json["channelLevels"] = legacy; diff --git a/tests/test_digico_protocol.cpp b/tests/test_digico_protocol.cpp index 91496bb..01db432 100644 --- a/tests/test_digico_protocol.cpp +++ b/tests/test_digico_protocol.cpp @@ -4,11 +4,9 @@ using namespace OpenMix; -// DiGiCo publishes no OSC address map: Generic OSC is user-defined, and the -// syntax varies by model and software version. The driver therefore sends the -// operator's patterns and nothing else, so what is worth testing is that a -// pattern is expanded exactly as given and that a missing one sends nothing at -// all - a guessed address would look like it worked and do nothing on the desk. +// The driver sends the operator's OSC patterns and nothing else, so what is worth +// testing is that a pattern expands exactly as given and that a missing one sends +// nothing at all. class TestDiGiCoProtocol : public QObject { Q_OBJECT @@ -56,8 +54,7 @@ class TestDiGiCoProtocol : public QObject { void capabilities_areGenericOsc() { // Generic OSC is UDP, and the port is whatever the operator paired; 9000 - // is only the common default. It is not Allen & Heath's 51321, which this - // driver used to point at while speaking Yamaha's protocol. + // is only the common default. const auto caps = MixerCapabilities::forConsole(ConsoleType::SD12); QCOMPARE(caps.protocol, ProtocolType::OscUdp); QCOMPARE(caps.defaultPort, 9000);