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/src/app/Application.cpp b/src/app/Application.cpp index db4b13c..c0b9a5c 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -27,7 +27,10 @@ #include "midi/MidiInputManager.h" #include "protocol/MixerProtocol.h" #include "protocol/ProtocolFactory.h" +#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" @@ -374,6 +377,25 @@ 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); + } + + if (auto* gld = dynamic_cast(m_mixer)) { + 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/Cue.cpp b/src/core/Cue.cpp index f893318..327ed80 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,19 @@ 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 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) { 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..4813140 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,21 @@ 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, 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 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; + + // 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. [[nodiscard]] QMap fxMutes() const { return m_fxMutes; } void setFxMutes(const QMap& mutes) { m_fxMutes = mutes; } @@ -285,12 +300,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..a8235af --- /dev/null +++ b/src/core/LevelDb.h @@ -0,0 +1,20 @@ +#pragma once + +namespace OpenMix { + +// 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 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/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/core/Show.cpp b/src/core/Show.cpp index 435c103..4cdd4c6 100644 --- a/src/core/Show.cpp +++ b/src/core/Show.cpp @@ -11,6 +11,12 @@ QJsonObject MixerConfig::toJson() const { json["host"] = host; json["port"] = port; json["dcaCount"] = dcaCount; + json["faderLaw"] = faderLaw; + json["midiChannel"] = midiChannel; + json["oscChannelFader"] = oscChannelFader; + json["oscChannelMute"] = oscChannelMute; + json["oscSceneRecall"] = oscSceneRecall; + json["oscReceivePort"] = oscReceivePort; return json; } @@ -20,12 +26,21 @@ 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"); + 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; + dcaCount == other.dcaCount && faderLaw == other.faderLaw && + 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 465eb51..e752c66 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,29 @@ 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"; + + // 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; + + // 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" + int oscReceivePort = 8000; + [[nodiscard]] bool operator==(const MixerConfig& other) const; [[nodiscard]] bool operator!=(const MixerConfig& other) const { return !(*this == other); } @@ -46,13 +64,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 +138,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 +193,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/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/MixerCapabilities.cpp b/src/protocol/MixerCapabilities.cpp index b0ea754..702ef8c 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; @@ -161,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; @@ -183,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; @@ -415,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; @@ -437,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; @@ -456,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; @@ -475,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/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/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..2180b8c 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,45 @@ 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, 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; } + 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/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..1f9008e 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 that 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,37 @@ 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, 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; + 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 +141,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 +162,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 +173,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 +183,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 +206,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/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..9f02cc0 100644 --- a/src/protocol/allenheath/GLDProtocol.h +++ b/src/protocol/allenheath/GLDProtocol.h @@ -1,42 +1,62 @@ #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, 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. +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; + + static constexpr int COLOUR_OFF = 0x00; + static constexpr int COLOUR_MAX = 0x07; + + // 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; + 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/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..30b4cf0 100644 --- a/src/protocol/allenheath/QuProtocol.h +++ b/src/protocol/allenheath/QuProtocol.h @@ -1,13 +1,17 @@ #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), 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 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 @@ -16,10 +20,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/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..49ab6eb 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,49 @@ 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 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) { + 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 +80,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 +225,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 +259,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 +322,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 +390,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 +476,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 +551,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 +562,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 +585,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 +599,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..11dc463 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,14 @@ 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 + // often enough that a dropped one still leaves another inside the window 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; 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..78a24b0 100644 --- a/src/protocol/digico/DiGiCoProtocol.h +++ b/src/protocol/digico/DiGiCoProtocol.h @@ -2,26 +2,19 @@ #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. -// -// 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. +// 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 @@ -31,7 +24,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 +43,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/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..67aa4ac 100644 --- a/src/protocol/discovery/probes/AllenHeathProbeStrategy.h +++ b/src/protocol/discovery/probes/AllenHeathProbeStrategy.h @@ -9,11 +9,12 @@ 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...") 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; } @@ -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/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/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/ConnectionPanel.cpp b/src/ui/ConnectionPanel.cpp index a9cbc8d..131a07e 100644 --- a/src/ui/ConnectionPanel.cpp +++ b/src/ui/ConnectionPanel.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include namespace OpenMix { @@ -100,6 +101,47 @@ 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_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); + + // 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); @@ -350,6 +392,24 @@ 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); + + // GLD stamps its MIDI channel into every message it sends + const bool hasMidiChannel = type.startsWith("gld"); + 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)); } @@ -429,6 +489,13 @@ 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); + m_midiChannelSpin->setValue(config.midiChannel); + m_oscFaderEdit->setText(config.oscChannelFader); + m_oscMuteEdit->setText(config.oscChannelMute); + m_oscSceneEdit->setText(config.oscSceneRecall); } void ConnectionPanel::saveToConfig() { @@ -437,6 +504,11 @@ 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(); + 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 fa92134..c8a6eac 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; @@ -52,6 +53,17 @@ class ConnectionPanel : public QWidget { QLineEdit* m_portEdit; QLabel* m_hostLabel; QLabel* m_portLabel; + QComboBox* m_faderLawCombo; + 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/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/CMakeLists.txt b/tests/CMakeLists.txt index 0b744df..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 @@ -326,7 +327,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 @@ -347,9 +347,35 @@ 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/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 ${CMAKE_SOURCE_DIR}/src/protocol/MixerCapabilities.cpp diff --git a/tests/test_allenheath_midi.cpp b/tests/test_allenheath_midi.cpp index 7c24084..ede027b 100644 --- a/tests/test_allenheath_midi.cpp +++ b/tests/test_allenheath_midi.cpp @@ -1,16 +1,47 @@ +#include "core/Cue.h" #include "protocol/MixerCapabilities.h" #include "protocol/allenheath/AllenHeathMidiProtocol.h" +#include "protocol/allenheath/GLDProtocol.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 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: 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 +53,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 +87,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 +118,204 @@ 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_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 + 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); + } + + // --- 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 a7efc35..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; @@ -11,12 +10,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 { @@ -29,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 @@ -62,33 +57,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 +146,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() { @@ -146,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("f00000abcd0000160000028000f7")); - QCOMPARE(p.buildChannelMute(H, 1, true), QByteArray::fromHex("f00000abcd00001690000101f7")); - QCOMPARE(p.buildDcaMute(H, 1, true), QByteArray::fromHex("f00000abcd00001010000101f7")); - } - - 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_allenheath_session.cpp b/tests/test_allenheath_session.cpp new file mode 100644 index 0000000..794d5da --- /dev/null +++ b/tests/test_allenheath_session.cpp @@ -0,0 +1,222 @@ +#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: 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 + + 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" 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)); } }; diff --git a/tests/test_cue.cpp b/tests/test_cue.cpp index c262cb7..e295c48 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 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 position 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_digico_protocol.cpp b/tests/test_digico_protocol.cpp index 83aa1cd..01db432 100644 --- a/tests/test_digico_protocol.cpp +++ b/tests/test_digico_protocol.cpp @@ -1,77 +1,64 @@ #include "protocol/MixerCapabilities.h" #include "protocol/digico/DiGiCoProtocol.h" -#include #include using namespace OpenMix; +// 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 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. + 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_mixer_capabilities.cpp b/tests/test_mixer_capabilities.cpp index b7dab31..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); @@ -41,7 +42,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 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.cpp b/tests/test_show.cpp index 55a1a86..ed92295 100644 --- a/tests/test_show.cpp +++ b/tests/test_show.cpp @@ -59,6 +59,43 @@ 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 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(); 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); } 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"));