From 168314ee8c1ac68ebd8840e23de94beac9a92fc6 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Tue, 30 Jun 2026 18:46:37 -0400 Subject: [PATCH 01/92] feat: core backend - actor profiles, fades, Yamaha SCP, show control --- CMakeLists.txt | 14 + src/app/Application.cpp | 49 ++ src/app/Application.h | 14 + src/app/OscRemoteServer.cpp | 165 +++++ src/app/OscRemoteServer.h | 63 ++ src/app/QLabClient.cpp | 79 +++ src/app/QLabClient.h | 60 ++ src/core/Actor.cpp | 53 ++ src/core/Actor.h | 54 ++ src/core/ActorProfile.cpp | 110 +++ src/core/ActorProfile.h | 68 ++ src/core/ActorProfileLibrary.cpp | 149 ++++ src/core/ActorProfileLibrary.h | 68 ++ src/core/Cue.cpp | 44 ++ src/core/Cue.h | 31 + src/core/FadeCurve.h | 33 + src/core/FadeEngine.cpp | 106 +++ src/core/FadeEngine.h | 70 ++ src/core/PlaybackEngine.cpp | 134 +++- src/core/PlaybackEngine.h | 26 + src/core/Show.cpp | 24 +- src/core/Show.h | 8 + src/midi/MidiInputManager.cpp | 43 +- src/midi/MidiInputManager.h | 10 + src/midi/MscParser.h | 58 ++ src/protocol/LoopbackProtocol.cpp | 45 ++ src/protocol/LoopbackProtocol.h | 17 + src/protocol/MixerCapabilities.cpp | 18 +- src/protocol/MixerProtocol.cpp | 10 + src/protocol/MixerProtocol.h | 20 + src/protocol/behringer/WingProtocol.cpp | 67 ++ src/protocol/behringer/WingProtocol.h | 12 + src/protocol/behringer/X32Protocol.cpp | 82 +++ src/protocol/behringer/X32Protocol.h | 12 + src/protocol/yamaha/YamahaCLProtocol.cpp | 17 +- src/protocol/yamaha/YamahaCLProtocol.h | 9 +- src/protocol/yamaha/YamahaDM7Protocol.cpp | 19 +- src/protocol/yamaha/YamahaDM7Protocol.h | 10 +- src/protocol/yamaha/YamahaProtocol.cpp | 815 +++++++++++----------- src/protocol/yamaha/YamahaProtocol.h | 173 +++-- src/protocol/yamaha/YamahaQLProtocol.cpp | 17 +- src/protocol/yamaha/YamahaQLProtocol.h | 9 +- src/protocol/yamaha/YamahaTFProtocol.cpp | 17 +- src/protocol/yamaha/YamahaTFProtocol.h | 11 +- tests/CMakeLists.txt | 119 +++- tests/test_actor_profiles.cpp | 203 ++++++ tests/test_fade_engine.cpp | 79 +++ tests/test_msc.cpp | 61 ++ tests/test_osc_remote.cpp | 72 ++ tests/test_playback_profiles.cpp | 186 +++++ tests/test_qlab_client.cpp | 75 ++ tests/test_yamaha_osc.cpp | 80 --- tests/test_yamaha_scp.cpp | 190 +++++ 53 files changed, 3313 insertions(+), 665 deletions(-) create mode 100644 src/app/OscRemoteServer.cpp create mode 100644 src/app/OscRemoteServer.h create mode 100644 src/app/QLabClient.cpp create mode 100644 src/app/QLabClient.h create mode 100644 src/core/Actor.cpp create mode 100644 src/core/Actor.h create mode 100644 src/core/ActorProfile.cpp create mode 100644 src/core/ActorProfile.h create mode 100644 src/core/ActorProfileLibrary.cpp create mode 100644 src/core/ActorProfileLibrary.h create mode 100644 src/core/FadeCurve.h create mode 100644 src/core/FadeEngine.cpp create mode 100644 src/core/FadeEngine.h create mode 100644 src/midi/MscParser.h create mode 100644 tests/test_actor_profiles.cpp create mode 100644 tests/test_fade_engine.cpp create mode 100644 tests/test_msc.cpp create mode 100644 tests/test_osc_remote.cpp create mode 100644 tests/test_playback_profiles.cpp create mode 100644 tests/test_qlab_client.cpp delete mode 100644 tests/test_yamaha_osc.cpp create mode 100644 tests/test_yamaha_scp.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e278832..73fd8af 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -78,6 +78,8 @@ endif() set(SOURCES src/main.cpp src/app/Application.cpp + src/app/OscRemoteServer.cpp + src/app/QLabClient.cpp src/core/Cue.cpp src/core/CueList.cpp src/core/Show.cpp @@ -90,6 +92,10 @@ set(SOURCES src/core/ShortcutManager.cpp src/core/OperationMode.cpp src/core/DCAMapping.cpp + src/core/ActorProfile.cpp + src/core/Actor.cpp + src/core/ActorProfileLibrary.cpp + src/core/FadeEngine.cpp src/core/AppLogger.cpp src/core/ConnectionLogBridge.cpp src/ui/MainWindow.cpp @@ -158,6 +164,8 @@ set(SOURCES # header files set(HEADERS src/app/Application.h + src/app/OscRemoteServer.h + src/app/QLabClient.h src/core/Cue.h src/core/CueList.h src/core/Show.h @@ -170,6 +178,11 @@ set(HEADERS src/core/ShortcutManager.h src/core/OperationMode.h src/core/DCAMapping.h + src/core/ActorProfile.h + src/core/Actor.h + src/core/ActorProfileLibrary.h + src/core/FadeCurve.h + src/core/FadeEngine.h src/core/AppLogger.h src/core/ConnectionLogBridge.h src/ui/MainWindow.h @@ -229,6 +242,7 @@ set(HEADERS src/midi/MidiControlMapping.h src/midi/MidiInputManager.h + src/midi/MscParser.h src/ui/MidiConfigDialog.h src/ui/KeyboardShortcutsDialog.h src/ui/LogViewerDialog.h diff --git a/src/app/Application.cpp b/src/app/Application.cpp index 78d42d0..0d22988 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -1,4 +1,6 @@ #include "Application.h" +#include "OscRemoteServer.h" +#include "QLabClient.h" #include "ui/MainWindow.h" #include "core/AppLogger.h" #include "core/ConnectionLogBridge.h" @@ -58,6 +60,12 @@ Application::Application(QObject* parent) : QObject(parent) { m_discoveryService->registerStrategy(std::make_shared()); m_discoveryService->registerStrategy(std::make_shared()); + // inbound OSC remote control (QLab / stage-manager) + m_oscRemoteServer = new OscRemoteServer(this); + + // outbound QLab / DAW remote + m_qLabClient = new QLabClient(this); + // application logging m_appLogger = new AppLogger(this); m_connectionLogBridge = new ConnectionLogBridge(m_appLogger, this); @@ -83,6 +91,7 @@ Application::~Application() { void Application::initialize() { m_playbackEngine->setCueList(m_show->cueList()); m_playbackEngine->setDCAMapping(m_show->dcaMapping()); + m_playbackEngine->setActorLibrary(m_show->actorProfileLibrary()); if (m_mixer) { m_playbackEngine->setMixer(m_mixer); @@ -91,6 +100,18 @@ void Application::initialize() { m_playbackEngine->setValidator(m_cueValidator); m_playbackEngine->setGuard(m_playbackGuard); m_playbackEngine->setLogger(m_playbackLogger); + m_playbackEngine->setVerifyCues(true); + + connect(m_playbackEngine, &PlaybackEngine::cueDrifted, this, + [this](int index, const QStringList& paths) { + if (m_appLogger) { + m_appLogger->log(LogLevel::Warning, LogSource::Playback, + QString("Cue %1 drift on %2 parameter(s): %3") + .arg(index) + .arg(paths.size()) + .arg(paths.join(", "))); + } + }); m_dryRunEngine->setCueList(m_show->cueList()); m_dryRunEngine->setValidator(m_cueValidator); @@ -131,6 +152,34 @@ void Application::initialize() { m_midiInputManager->setPlaybackEngine(m_playbackEngine); m_midiInputManager->setPlaybackGuard(m_playbackGuard); m_midiInputManager->loadFromSettings(); + + // inbound OSC remote control: /cue/go, /ctrl/fadeall, next/prev/goto + m_oscRemoteServer->setPlaybackEngine(m_playbackEngine); + connect(m_oscRemoteServer, &OscRemoteServer::fadeAllRequested, this, [this]() { + if (m_playbackGuard) + m_playbackGuard->panic(); + }); + m_oscRemoteServer->loadFromSettings(); + if (m_oscRemoteServer->start(m_oscRemoteServer->port())) { + m_appLogger->log(LogLevel::Info, LogSource::System, + QString("OSC remote control listening on port %1") + .arg(m_oscRemoteServer->port())); + } else { + m_appLogger->log(LogLevel::Warning, LogSource::System, + QString("OSC remote control could not bind port %1") + .arg(m_oscRemoteServer->port())); + } + + // outbound QLab/DAW remote: fire a linked QLab cue when a cue executes + m_qLabClient->loadFromSettings(); + connect(m_playbackEngine, &PlaybackEngine::cueExecuted, this, [this](int index) { + if (!m_qLabClient->isEnabled() || index < 0 || !m_show->cueList() || + index >= m_show->cueList()->count()) + return; + const Cue& cue = m_show->cueList()->at(index); + if (!cue.qLabCue().isEmpty()) + m_qLabClient->triggerCue(cue.qLabCue()); + }); } void Application::setupMixerConnection(const QString& type, const QString& host, int port) { diff --git a/src/app/Application.h b/src/app/Application.h index 98fd84e..d4a2622 100644 --- a/src/app/Application.h +++ b/src/app/Application.h @@ -22,6 +22,8 @@ class MidiInputManager; class ConsoleDiscoveryService; class AppLogger; class ConnectionLogBridge; +class OscRemoteServer; +class QLabClient; struct DiscoveredConsole; class Application : public QObject { @@ -63,6 +65,12 @@ class Application : public QObject { // application logging [[nodiscard]] AppLogger* appLogger() { return m_appLogger; } + // inbound OSC remote control (QLab / stage-manager) + [[nodiscard]] OscRemoteServer* oscRemoteServer() { return m_oscRemoteServer; } + + // outbound QLab / DAW remote + [[nodiscard]] QLabClient* qLabClient() { return m_qLabClient; } + // mixer connection void connectToMixer(const QString& type, const QString& host, int port); void connectToDiscoveredConsole(const DiscoveredConsole& console); @@ -118,6 +126,12 @@ class Application : public QObject { // application logging AppLogger* m_appLogger; ConnectionLogBridge* m_connectionLogBridge; + + // inbound OSC remote control + OscRemoteServer* m_oscRemoteServer; + + // outbound QLab / DAW remote + QLabClient* m_qLabClient; }; } // namespace OpenMix diff --git a/src/app/OscRemoteServer.cpp b/src/app/OscRemoteServer.cpp new file mode 100644 index 0000000..f0f5ad6 --- /dev/null +++ b/src/app/OscRemoteServer.cpp @@ -0,0 +1,165 @@ +#include "OscRemoteServer.h" +#include "core/PlaybackEngine.h" + +#include +#include + +namespace OpenMix { + +namespace { +void onLoError(int num, const char* msg, const char* where) { + Q_UNUSED(num); + Q_UNUSED(msg); + Q_UNUSED(where); +} + +OscRemoteServer* server(void* user) { return static_cast(user); } + +int hGo(const char*, const char*, lo_arg**, int, lo_message, void* u) { + server(u)->deliver(OscRemoteServer::Command::Go); + return 0; +} +int hStop(const char*, const char*, lo_arg**, int, lo_message, void* u) { + server(u)->deliver(OscRemoteServer::Command::Stop); + return 0; +} +int hNext(const char*, const char*, lo_arg**, int, lo_message, void* u) { + server(u)->deliver(OscRemoteServer::Command::Next); + return 0; +} +int hPrev(const char*, const char*, lo_arg**, int, lo_message, void* u) { + server(u)->deliver(OscRemoteServer::Command::Prev); + return 0; +} +int hFadeAll(const char*, const char*, lo_arg**, int, lo_message, void* u) { + server(u)->deliver(OscRemoteServer::Command::FadeAll); + return 0; +} +int hGoto(const char*, const char* types, lo_arg** argv, int argc, lo_message, void* u) { + if (argc < 1 || !types) + return 0; + double value = 0.0; + switch (types[0]) { + case 'i': + value = argv[0]->i; + break; + case 'f': + value = argv[0]->f; + break; + case 'h': + value = static_cast(argv[0]->h); + break; + case 'd': + value = argv[0]->d; + break; + default: + return 0; + } + server(u)->deliver(OscRemoteServer::Command::Goto, value); + return 0; +} +} // namespace + +OscRemoteServer::OscRemoteServer(QObject* parent) : QObject(parent) {} + +OscRemoteServer::~OscRemoteServer() { stop(); } + +bool OscRemoteServer::start(int port) { + if (m_server) + stop(); + + const QByteArray portStr = port > 0 ? QByteArray::number(port) : QByteArray(); + m_server = lo_server_thread_new(port > 0 ? portStr.constData() : nullptr, onLoError); + if (!m_server) + return false; + + registerMethods(); + + if (lo_server_thread_start(m_server) < 0) { + lo_server_thread_free(m_server); + m_server = nullptr; + return false; + } + + m_port = lo_server_thread_get_port(m_server); + emit started(m_port); + return true; +} + +void OscRemoteServer::stop() { + if (!m_server) + return; + lo_server_thread_stop(m_server); + lo_server_thread_free(m_server); + m_server = nullptr; + emit stopped(); +} + +void OscRemoteServer::registerMethods() { + lo_server_thread_add_method(m_server, "/go", nullptr, hGo, this); + lo_server_thread_add_method(m_server, "/cue/go", nullptr, hGo, this); + lo_server_thread_add_method(m_server, "/ctrl/go", nullptr, hGo, this); + lo_server_thread_add_method(m_server, "/stop", nullptr, hStop, this); + lo_server_thread_add_method(m_server, "/cue/stop", nullptr, hStop, this); + lo_server_thread_add_method(m_server, "/ctrl/stop", nullptr, hStop, this); + lo_server_thread_add_method(m_server, "/next", nullptr, hNext, this); + lo_server_thread_add_method(m_server, "/prev", nullptr, hPrev, this); + lo_server_thread_add_method(m_server, "/previous", nullptr, hPrev, this); + lo_server_thread_add_method(m_server, "/ctrl/fadeall", nullptr, hFadeAll, this); + lo_server_thread_add_method(m_server, "/cue/goto", nullptr, hGoto, this); +} + +void OscRemoteServer::deliver(Command command, double arg) { + // hop from the liblo server thread onto this object's (main) thread + QMetaObject::invokeMethod( + this, + [this, command, arg]() { + switch (command) { + case Command::Go: + emit goRequested(); + break; + case Command::Stop: + emit stopRequested(); + break; + case Command::Next: + emit nextRequested(); + break; + case Command::Prev: + emit prevRequested(); + break; + case Command::FadeAll: + emit fadeAllRequested(); + break; + case Command::Goto: + emit gotoRequested(arg); + break; + } + }, + Qt::QueuedConnection); +} + +void OscRemoteServer::setPlaybackEngine(PlaybackEngine* engine) { + if (!engine) + return; + connect(this, &OscRemoteServer::goRequested, engine, &PlaybackEngine::go); + connect(this, &OscRemoteServer::stopRequested, engine, &PlaybackEngine::stop); + connect(this, &OscRemoteServer::nextRequested, engine, &PlaybackEngine::next); + connect(this, &OscRemoteServer::prevRequested, engine, &PlaybackEngine::previous); + connect(this, &OscRemoteServer::gotoRequested, engine, &PlaybackEngine::goToNumber); +} + +void OscRemoteServer::loadFromSettings() { + QSettings settings; + settings.beginGroup("OscRemote"); + m_port = settings.value("port", 8000).toInt(); + settings.endGroup(); +} + +void OscRemoteServer::saveToSettings() { + QSettings settings; + settings.beginGroup("OscRemote"); + settings.setValue("port", m_port); + settings.endGroup(); +} + +} // namespace OpenMix diff --git a/src/app/OscRemoteServer.h b/src/app/OscRemoteServer.h new file mode 100644 index 0000000..f85c29a --- /dev/null +++ b/src/app/OscRemoteServer.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include + +namespace OpenMix { + +class PlaybackEngine; + +// Inbound OSC control surface so external apps (QLab, a stage-manager remote) +// can drive the cue stack. Listens on a UDP port via a liblo server thread and +// re-emits each command as a Qt signal marshaled onto this object's thread. +// +// Supported addresses: +// /go, /cue/go, /ctrl/go -> GO (fire standby cue) +// /stop, /cue/stop, /ctrl/stop -> STOP +// /next -> standby next +// /prev, /previous -> standby previous +// /ctrl/fadeall -> fade everything to safe +// /cue/goto (int|float arg) -> standby cue by number +class OscRemoteServer : public QObject { + Q_OBJECT + + public: + enum class Command { Go, Stop, Next, Prev, FadeAll, Goto }; + + explicit OscRemoteServer(QObject* parent = nullptr); + ~OscRemoteServer() override; + + // start listening. port <= 0 picks a free port (see port()). Returns false if + // the port could not be bound. + bool start(int port); + void stop(); + [[nodiscard]] bool isRunning() const { return m_server != nullptr; } + [[nodiscard]] int port() const { return m_port; } + + // wire the standard control signals to a playback engine's slots + void setPlaybackEngine(PlaybackEngine* engine); + + void loadFromSettings(); + void saveToSettings(); + + // called by the (non-Qt) liblo server thread; marshals onto this thread + void deliver(Command command, double arg = 0.0); + + signals: + void goRequested(); + void stopRequested(); + void nextRequested(); + void prevRequested(); + void fadeAllRequested(); + void gotoRequested(double cueNumber); + void started(int port); + void stopped(); + + private: + void registerMethods(); + + lo_server_thread m_server = nullptr; + int m_port = 0; +}; + +} // namespace OpenMix diff --git a/src/app/QLabClient.cpp b/src/app/QLabClient.cpp new file mode 100644 index 0000000..86eb137 --- /dev/null +++ b/src/app/QLabClient.cpp @@ -0,0 +1,79 @@ +#include "QLabClient.h" + +#include +#include +#include + +namespace OpenMix { + +QLabClient::QLabClient(QObject* parent) : QObject(parent) { rebuildAddress(); } + +QLabClient::~QLabClient() { + if (m_address) + lo_address_free(m_address); +} + +void QLabClient::setTarget(const QString& host, int port) { + m_host = host; + m_port = port > 0 ? port : QLAB_DEFAULT_PORT; + rebuildAddress(); +} + +void QLabClient::rebuildAddress() { + if (m_address) { + lo_address_free(m_address); + m_address = nullptr; + } + m_address = + lo_address_new(m_host.toUtf8().constData(), QByteArray::number(m_port).constData()); +} + +QString QLabClient::prefix() const { + return m_workspaceId.isEmpty() ? QString() : QStringLiteral("/workspace/") + m_workspaceId; +} + +void QLabClient::send(const QString& address) { + if (!m_enabled || !m_address) + return; + lo_send(m_address, address.toUtf8().constData(), ""); + emit sent(address); +} + +void QLabClient::triggerCue(const QString& cueId) { + if (!m_enabled || cueId.isEmpty()) + return; + + const QString address = prefix() + QStringLiteral("/cue/") + cueId + QStringLiteral("/start"); + if (m_preRollMs > 0) { + QTimer::singleShot(m_preRollMs, this, [this, address]() { send(address); }); + } else { + send(address); + } +} + +void QLabClient::go() { send(prefix() + QStringLiteral("/go")); } + +void QLabClient::loadFromSettings() { + QSettings settings; + settings.beginGroup("QLab"); + m_host = settings.value("host", m_host).toString(); + m_port = settings.value("port", m_port).toInt(); + m_enabled = settings.value("enabled", false).toBool(); + m_preRollMs = settings.value("preRollMs", 0).toInt(); + m_workspaceId = settings.value("workspaceId").toString(); + settings.endGroup(); + rebuildAddress(); +} + +void QLabClient::saveToSettings() { + QSettings settings; + settings.beginGroup("QLab"); + settings.setValue("host", m_host); + settings.setValue("port", m_port); + settings.setValue("enabled", m_enabled); + settings.setValue("preRollMs", m_preRollMs); + settings.setValue("workspaceId", m_workspaceId); + settings.endGroup(); +} + +} // namespace OpenMix diff --git a/src/app/QLabClient.h b/src/app/QLabClient.h new file mode 100644 index 0000000..0b8e966 --- /dev/null +++ b/src/app/QLabClient.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include + +namespace OpenMix { + +// Outbound DAW remote: fires cues in QLab (or any OSC-controllable playback app) +// when an OpenMix cue carrying a linked QLab cue id is executed. Mirrors +// TheatreMix's dawRemote / qLabCue feature, including an optional pre-roll delay. +class QLabClient : public QObject { + Q_OBJECT + + public: + static constexpr int QLAB_DEFAULT_PORT = 53000; + + explicit QLabClient(QObject* parent = nullptr); + ~QLabClient() override; + + void setTarget(const QString& host, int port); + [[nodiscard]] QString host() const { return m_host; } + [[nodiscard]] int port() const { return m_port; } + + void setEnabled(bool enabled) { m_enabled = enabled; } + [[nodiscard]] bool isEnabled() const { return m_enabled; } + + void setPreRollMs(int ms) { m_preRollMs = ms < 0 ? 0 : ms; } + [[nodiscard]] int preRollMs() const { return m_preRollMs; } + + // optional QLab workspace id; when set, addresses are /workspace//... + void setWorkspaceId(const QString& id) { m_workspaceId = id; } + [[nodiscard]] QString workspaceId() const { return m_workspaceId; } + + void loadFromSettings(); + void saveToSettings(); + + public slots: + // fire a specific QLab cue by number/id (respects the pre-roll delay) + void triggerCue(const QString& cueId); + // GO on the QLab workspace + void go(); + + signals: + void sent(const QString& address); + + private: + void rebuildAddress(); + void send(const QString& address); + [[nodiscard]] QString prefix() const; // "" or "/workspace/" + + lo_address m_address = nullptr; + QString m_host = QStringLiteral("127.0.0.1"); + int m_port = QLAB_DEFAULT_PORT; + bool m_enabled = false; + int m_preRollMs = 0; + QString m_workspaceId; +}; + +} // namespace OpenMix diff --git a/src/core/Actor.cpp b/src/core/Actor.cpp new file mode 100644 index 0000000..b0ef85c --- /dev/null +++ b/src/core/Actor.cpp @@ -0,0 +1,53 @@ +#include "Actor.h" + +#include +#include + +namespace OpenMix { + +Actor::Actor() : m_id(QUuid::createUuid().toString(QUuid::WithoutBraces)) {} + +Actor::Actor(const QString& name, int channel) + : m_id(QUuid::createUuid().toString(QUuid::WithoutBraces)), m_name(name), m_channel(channel) {} + +void Actor::regenerateId() { m_id = QUuid::createUuid().toString(QUuid::WithoutBraces); } + +QJsonObject Actor::toJson() const { + QJsonObject json; + json["id"] = m_id; + json["name"] = m_name; + json["channel"] = m_channel; + json["order"] = m_order; + json["active"] = m_active; + + if (!m_profiles.isEmpty()) { + QJsonObject profilesObj; + for (auto it = m_profiles.constBegin(); it != m_profiles.constEnd(); ++it) { + QJsonObject p = it.value().toJson(); + if (!p.isEmpty()) + profilesObj[it.key()] = p; + } + if (!profilesObj.isEmpty()) + json["profiles"] = profilesObj; + } + return json; +} + +Actor Actor::fromJson(const QJsonObject& json) { + Actor actor; + actor.m_id = json["id"].toString(); + if (actor.m_id.isEmpty()) + actor.m_id = QUuid::createUuid().toString(QUuid::WithoutBraces); + actor.m_name = json["name"].toString(); + actor.m_channel = json["channel"].toInt(); + actor.m_order = json["order"].toInt(); + actor.m_active = json["active"].toBool(true); + + const QJsonObject profilesObj = json["profiles"].toObject(); + for (auto it = profilesObj.constBegin(); it != profilesObj.constEnd(); ++it) + actor.m_profiles[it.key()] = ActorProfile::fromJson(it.value().toObject()); + + return actor; +} + +} // namespace OpenMix diff --git a/src/core/Actor.h b/src/core/Actor.h new file mode 100644 index 0000000..bbf1149 --- /dev/null +++ b/src/core/Actor.h @@ -0,0 +1,54 @@ +#pragma once + +#include "ActorProfile.h" +#include +#include +#include +#include + +namespace OpenMix { + +// A cast member assigned to a console input channel. Holds one ActorProfile per +// profile slot (e.g. "Main", "Solo"); the active slot is chosen per-cue. Profiles +// follow the actor, so swapping in an understudy swaps the whole voice set. +class Actor { + public: + Actor(); + explicit Actor(const QString& name, int channel = 0); + + [[nodiscard]] QString id() const { return m_id; } + void setId(const QString& id) { m_id = id; } + void regenerateId(); + + [[nodiscard]] QString name() const { return m_name; } + void setName(const QString& name) { m_name = name; } + + [[nodiscard]] int channel() const noexcept { return m_channel; } + void setChannel(int channel) { m_channel = channel; } + + [[nodiscard]] int order() const noexcept { return m_order; } + void setOrder(int order) { m_order = order; } + + [[nodiscard]] bool active() const noexcept { return m_active; } + void setActive(bool active) { m_active = active; } + + // per-slot profiles + [[nodiscard]] bool hasProfile(const QString& slot) const { return m_profiles.contains(slot); } + [[nodiscard]] ActorProfile profile(const QString& slot) const { return m_profiles.value(slot); } + void setProfile(const QString& slot, const ActorProfile& profile) { m_profiles[slot] = profile; } + void removeProfile(const QString& slot) { m_profiles.remove(slot); } + [[nodiscard]] QStringList profileSlots() const { return m_profiles.keys(); } + + QJsonObject toJson() const; + [[nodiscard]] static Actor fromJson(const QJsonObject& json); + + private: + QString m_id; + QString m_name; + int m_channel = 0; + int m_order = 0; + bool m_active = true; + QMap m_profiles; // slot -> profile +}; + +} // namespace OpenMix diff --git a/src/core/ActorProfile.cpp b/src/core/ActorProfile.cpp new file mode 100644 index 0000000..9cc21ce --- /dev/null +++ b/src/core/ActorProfile.cpp @@ -0,0 +1,110 @@ +#include "ActorProfile.h" + +#include + +namespace OpenMix { + +QJsonObject EqBand::toJson() const { + QJsonObject json; + json["band"] = band; + json["on"] = on; + json["type"] = type; + json["freq"] = freq; + json["gain"] = gain; + json["q"] = q; + return json; +} + +EqBand EqBand::fromJson(const QJsonObject& json) { + EqBand b; + b.band = json["band"].toInt(1); + b.on = json["on"].toBool(true); + b.type = json["type"].toInt(0); + b.freq = json["freq"].toDouble(1000.0); + b.gain = json["gain"].toDouble(0.0); + b.q = json["q"].toDouble(2.0); + return b; +} + +bool VoiceData::isEmpty() const { + return !gainDb && !hpfOn && !hpfFreq && !eqOn && eqBands.isEmpty() && !dynOn && + !dynThreshold && !dynRatio && !dynAttack && !dynRelease && !dynGain; +} + +QJsonObject VoiceData::toJson() const { + QJsonObject json; + if (gainDb) + json["gain"] = *gainDb; + if (hpfOn) + json["hpfOn"] = *hpfOn; + if (hpfFreq) + json["hpfFreq"] = *hpfFreq; + if (eqOn) + json["eqOn"] = *eqOn; + if (!eqBands.isEmpty()) { + QJsonArray arr; + for (const EqBand& b : eqBands) + arr.append(b.toJson()); + json["eq"] = arr; + } + if (dynOn) + json["dynOn"] = *dynOn; + if (dynThreshold) + json["dynThreshold"] = *dynThreshold; + if (dynRatio) + json["dynRatio"] = *dynRatio; + if (dynAttack) + json["dynAttack"] = *dynAttack; + if (dynRelease) + json["dynRelease"] = *dynRelease; + if (dynGain) + json["dynGain"] = *dynGain; + return json; +} + +VoiceData VoiceData::fromJson(const QJsonObject& json) { + VoiceData v; + if (json.contains("gain")) + v.gainDb = json["gain"].toDouble(); + if (json.contains("hpfOn")) + v.hpfOn = json["hpfOn"].toBool(); + if (json.contains("hpfFreq")) + v.hpfFreq = json["hpfFreq"].toDouble(); + if (json.contains("eqOn")) + v.eqOn = json["eqOn"].toBool(); + if (json.contains("eq")) { + for (const QJsonValue& val : json["eq"].toArray()) + v.eqBands.append(EqBand::fromJson(val.toObject())); + } + if (json.contains("dynOn")) + v.dynOn = json["dynOn"].toBool(); + if (json.contains("dynThreshold")) + v.dynThreshold = json["dynThreshold"].toDouble(); + if (json.contains("dynRatio")) + v.dynRatio = json["dynRatio"].toDouble(); + if (json.contains("dynAttack")) + v.dynAttack = json["dynAttack"].toDouble(); + if (json.contains("dynRelease")) + v.dynRelease = json["dynRelease"].toDouble(); + if (json.contains("dynGain")) + v.dynGain = json["dynGain"].toDouble(); + return v; +} + +QJsonObject ActorProfile::toJson() const { + QJsonObject json; + if (!m_main.isEmpty()) + json["main"] = m_main.toJson(); + if (!m_backup.isEmpty()) + json["backup"] = m_backup.toJson(); + return json; +} + +ActorProfile ActorProfile::fromJson(const QJsonObject& json) { + ActorProfile p; + p.m_main = VoiceData::fromJson(json["main"].toObject()); + p.m_backup = VoiceData::fromJson(json["backup"].toObject()); + return p; +} + +} // namespace OpenMix diff --git a/src/core/ActorProfile.h b/src/core/ActorProfile.h new file mode 100644 index 0000000..2e3e469 --- /dev/null +++ b/src/core/ActorProfile.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include +#include + +namespace OpenMix { + +// One EQ band of a channel voice. Values are real-world units; the mixer driver +// scales them to the console's wire format. +struct EqBand { + int band = 1; // 1-based band index + bool on = true; // + int type = 0; // console EQ type enum (PEQ / shelf / ...), driver-mapped + double freq = 1000.0; // Hz + double gain = 0.0; // dB + double q = 2.0; // + + QJsonObject toJson() const; + [[nodiscard]] static EqBand fromJson(const QJsonObject& json); +}; + +// A channel "voice" — the per-channel processing that makes up an actor's sound: +// preamp gain, HPF, EQ and dynamics. Every field is optional so a partial voice +// only touches the parameters it sets (matches DCAOverride semantics). +struct VoiceData { + std::optional gainDb; // preamp / head-amp gain + std::optional hpfOn; + std::optional hpfFreq; // Hz + std::optional eqOn; + QVector eqBands; + std::optional dynOn; + std::optional dynThreshold; // dB + std::optional dynRatio; + std::optional dynAttack; // ms + std::optional dynRelease; // ms + std::optional dynGain; // makeup dB + + [[nodiscard]] bool isEmpty() const; + + QJsonObject toJson() const; + [[nodiscard]] static VoiceData fromJson(const QJsonObject& json); +}; + +// An actor's stored voice for one profile slot: the main voice plus a backup copy +// (spare-mic safe set, mirroring TheatreMix's /backup/* data). +class ActorProfile { + public: + [[nodiscard]] const VoiceData& main() const { return m_main; } + [[nodiscard]] VoiceData& main() { return m_main; } + void setMain(const VoiceData& voice) { m_main = voice; } + + [[nodiscard]] const VoiceData& backup() const { return m_backup; } + [[nodiscard]] VoiceData& backup() { return m_backup; } + void setBackup(const VoiceData& voice) { m_backup = voice; } + + [[nodiscard]] bool isEmpty() const { return m_main.isEmpty() && m_backup.isEmpty(); } + + QJsonObject toJson() const; + [[nodiscard]] static ActorProfile fromJson(const QJsonObject& json); + + private: + VoiceData m_main; + VoiceData m_backup; +}; + +} // namespace OpenMix diff --git a/src/core/ActorProfileLibrary.cpp b/src/core/ActorProfileLibrary.cpp new file mode 100644 index 0000000..2ab0bc4 --- /dev/null +++ b/src/core/ActorProfileLibrary.cpp @@ -0,0 +1,149 @@ +#include "ActorProfileLibrary.h" + +#include + +namespace OpenMix { + +ActorProfileLibrary::ActorProfileLibrary(QObject* parent) : QObject(parent) {} + +void ActorProfileLibrary::setProfileSlots(const QStringList& slotList) { + m_slots = slotList.isEmpty() ? QStringList{DEFAULT_SLOT} : slotList; + emit changed(); +} + +void ActorProfileLibrary::addSlot(const QString& slot) { + if (slot.isEmpty() || m_slots.contains(slot)) + return; + m_slots.append(slot); + emit changed(); +} + +void ActorProfileLibrary::removeSlot(const QString& slot) { + if (m_slots.removeAll(slot) > 0) { + if (m_slots.isEmpty()) + m_slots.append(DEFAULT_SLOT); + emit changed(); + } +} + +int ActorProfileLibrary::indexOfActor(const QString& id) const { + for (int i = 0; i < m_actors.size(); ++i) { + if (m_actors[i].id() == id) + return i; + } + return -1; +} + +const Actor* ActorProfileLibrary::actorById(const QString& id) const { + int idx = indexOfActor(id); + return idx >= 0 ? &m_actors[idx] : nullptr; +} + +const Actor* ActorProfileLibrary::actorForChannel(int channel) const { + // the active actor on this channel; if several are assigned, lowest order wins + const Actor* best = nullptr; + for (const Actor& a : m_actors) { + if (a.channel() != channel || !a.active()) + continue; + if (!best || a.order() < best->order()) + best = &a; + } + return best; +} + +void ActorProfileLibrary::addActor(const Actor& actor) { + m_actors.append(actor); + emit actorAdded(actor.id()); + emit changed(); +} + +void ActorProfileLibrary::updateActor(const QString& id, const Actor& actor) { + int idx = indexOfActor(id); + if (idx < 0) + return; + m_actors[idx] = actor; + emit actorModified(id); + emit changed(); +} + +void ActorProfileLibrary::removeActor(const QString& id) { + int idx = indexOfActor(id); + if (idx < 0) + return; + m_actors.removeAt(idx); + emit actorRemoved(id); + emit changed(); +} + +void ActorProfileLibrary::clear() { + m_actors.clear(); + m_backupChannels.clear(); + m_slots = QStringList{DEFAULT_SLOT}; + emit changed(); +} + +void ActorProfileLibrary::setBackup(int channel, bool on) { + bool changedState = on ? !m_backupChannels.contains(channel) : m_backupChannels.contains(channel); + if (!changedState) + return; + if (on) + m_backupChannels.insert(channel); + else + m_backupChannels.remove(channel); + emit changed(); +} + +std::optional ActorProfileLibrary::voiceFor(int channel, const QString& slot) const { + const Actor* actor = actorForChannel(channel); + if (!actor || !actor->hasProfile(slot)) + return std::nullopt; + const ActorProfile profile = actor->profile(slot); + const VoiceData& voice = isBackup(channel) ? profile.backup() : profile.main(); + if (voice.isEmpty()) + return std::nullopt; + return voice; +} + +QJsonObject ActorProfileLibrary::toJson() const { + QJsonObject json; + + QJsonArray slotsArr; + for (const QString& s : m_slots) + slotsArr.append(s); + json["slots"] = slotsArr; + + QJsonArray actorsArr; + for (const Actor& a : m_actors) + actorsArr.append(a.toJson()); + json["actors"] = actorsArr; + + if (!m_backupChannels.isEmpty()) { + QJsonArray backupArr; + for (int ch : m_backupChannels) + backupArr.append(ch); + json["backupChannels"] = backupArr; + } + + return json; +} + +void ActorProfileLibrary::loadFromJson(const QJsonObject& json) { + m_actors.clear(); + m_backupChannels.clear(); + m_slots.clear(); + + for (const QJsonValue& val : json["slots"].toArray()) + m_slots.append(val.toString()); + if (m_slots.isEmpty()) + m_slots.append(DEFAULT_SLOT); + + for (const QJsonValue& val : json["actors"].toArray()) + m_actors.append(Actor::fromJson(val.toObject())); + + for (const QJsonValue& val : json["backupChannels"].toArray()) + m_backupChannels.insert(val.toInt()); + + emit changed(); +} + +} // namespace OpenMix diff --git a/src/core/ActorProfileLibrary.h b/src/core/ActorProfileLibrary.h new file mode 100644 index 0000000..94284ed --- /dev/null +++ b/src/core/ActorProfileLibrary.h @@ -0,0 +1,68 @@ +#pragma once + +#include "Actor.h" +#include +#include +#include +#include +#include +#include +#include + +namespace OpenMix { + +// Owns the show's cast: actors (each assigned to a channel), the set of profile +// slots, and which channels are currently on their backup/spare voice. Resolves +// the concrete voice to apply for a given channel + slot. Lives on the Show. +class ActorProfileLibrary : public QObject { + Q_OBJECT + + public: + static constexpr const char* DEFAULT_SLOT = "Main"; + + explicit ActorProfileLibrary(QObject* parent = nullptr); + + // profile slots (categories of voice), default {"Main"}. NOTE: not named slots() + // because `slots` is a Qt moc keyword macro. + [[nodiscard]] QStringList profileSlots() const { return m_slots; } + void setProfileSlots(const QStringList& slotList); + void addSlot(const QString& slot); + void removeSlot(const QString& slot); + + // actors + [[nodiscard]] const QList& actors() const { return m_actors; } + [[nodiscard]] int actorCount() const { return m_actors.size(); } + [[nodiscard]] const Actor* actorById(const QString& id) const; + [[nodiscard]] const Actor* actorForChannel(int channel) const; // active actor on channel + void addActor(const Actor& actor); + void updateActor(const QString& id, const Actor& actor); + void removeActor(const QString& id); + void clear(); + + // backup (spare-mic) channels: resolve to the backup voice instead of main + [[nodiscard]] bool isBackup(int channel) const { return m_backupChannels.contains(channel); } + void setBackup(int channel, bool on); + [[nodiscard]] QSet backupChannels() const { return m_backupChannels; } + + // resolve the voice to apply for channel+slot, honoring backup state. + // returns nullopt if no active actor / no stored voice. + [[nodiscard]] std::optional voiceFor(int channel, const QString& slot) const; + + QJsonObject toJson() const; + void loadFromJson(const QJsonObject& json); + + signals: + void changed(); + void actorAdded(const QString& id); + void actorModified(const QString& id); + void actorRemoved(const QString& id); + + private: + [[nodiscard]] int indexOfActor(const QString& id) const; + + QStringList m_slots{DEFAULT_SLOT}; + QList m_actors; + QSet m_backupChannels; +}; + +} // namespace OpenMix diff --git a/src/core/Cue.cpp b/src/core/Cue.cpp index f696a76..b4f2024 100644 --- a/src/core/Cue.cpp +++ b/src/core/Cue.cpp @@ -190,6 +190,10 @@ QJsonObject Cue::toJson() const { json["autoFollow"] = m_autoFollow; json["autoFollowDelay"] = m_autoFollowDelay; json["autoFollowCondition"] = autoFollowConditionToString(m_autoFollowCondition); + if (m_fadeTime > 0.0) { + json["fadeTime"] = m_fadeTime; + json["fadeCurve"] = fadeCurveToString(m_fadeCurve); + } json["parameters"] = m_parameters; // DCA targeting @@ -258,6 +262,9 @@ QJsonObject Cue::toJson() const { if (!m_group.isEmpty()) { json["group"] = m_group; } + if (!m_qLabCue.isEmpty()) { + json["qLabCue"] = m_qLabCue; + } if (!m_tags.isEmpty()) { QJsonArray tagsArray; for (const QString& tag : m_tags) { @@ -266,6 +273,24 @@ QJsonObject Cue::toJson() const { json["tags"] = tagsArray; } + // per-channel actor-profile slots: { "": "" } + if (!m_channelProfiles.isEmpty()) { + QJsonObject profilesObj; + for (auto it = m_channelProfiles.constBegin(); it != m_channelProfiles.constEnd(); ++it) { + profilesObj[QString::number(it.key())] = it.value(); + } + json["channelProfiles"] = profilesObj; + } + + // per-channel level overrides: { "": level } + 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; + } + return json; } @@ -282,6 +307,8 @@ Cue Cue::fromJson(const QJsonObject& json) { cue.m_autoFollow = json["autoFollow"].toBool(); cue.m_autoFollowDelay = json["autoFollowDelay"].toDouble(); cue.m_autoFollowCondition = stringToAutoFollowCondition(json["autoFollowCondition"].toString()); + cue.m_fadeTime = json["fadeTime"].toDouble(0.0); + cue.m_fadeCurve = stringToFadeCurve(json["fadeCurve"].toString()); cue.m_parameters = json["parameters"].toObject(); // DCA targeting @@ -347,6 +374,7 @@ Cue Cue::fromJson(const QJsonObject& json) { cue.m_stopBehavior = stringToStopBehavior(json["stopBehavior"].toString()); cue.m_group = json["group"].toString(); + cue.m_qLabCue = json["qLabCue"].toString(); if (json.contains("tags")) { QJsonArray tagsArray = json["tags"].toArray(); for (const QJsonValue& val : tagsArray) { @@ -354,6 +382,22 @@ Cue Cue::fromJson(const QJsonObject& json) { } } + // per-channel actor-profile slots + if (json.contains("channelProfiles")) { + const QJsonObject profilesObj = json["channelProfiles"].toObject(); + for (auto it = profilesObj.constBegin(); it != profilesObj.constEnd(); ++it) { + cue.m_channelProfiles[it.key().toInt()] = it.value().toString(); + } + } + + // per-channel level overrides + 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()] = it.value().toDouble(); + } + } + return cue; } diff --git a/src/core/Cue.h b/src/core/Cue.h index 5b2557d..e071a7d 100644 --- a/src/core/Cue.h +++ b/src/core/Cue.h @@ -1,5 +1,6 @@ #pragma once +#include "FadeCurve.h" #include #include #include @@ -80,6 +81,12 @@ class Cue { m_autoFollowCondition = condition; } + // fade transition applied to fader/level moves on fire (0 = instant) + [[nodiscard]] double fadeTime() const noexcept { return m_fadeTime; } + void setFadeTime(double seconds) { m_fadeTime = seconds; } + [[nodiscard]] FadeCurve fadeCurve() const noexcept { return m_fadeCurve; } + void setFadeCurve(FadeCurve curve) { m_fadeCurve = curve; } + // DCA targeting [[nodiscard]] QSet targetedDCAs() const { return m_targetedDCAs; } void setTargetedDCAs(const QSet& dcas) { m_targetedDCAs = dcas; } @@ -132,6 +139,10 @@ class Cue { [[nodiscard]] QString group() const { return m_group; } void setGroup(const QString& group) { m_group = group; } + // linked QLab cue id fired on execute (outbound DAW remote) + [[nodiscard]] QString qLabCue() const { return m_qLabCue; } + void setQLabCue(const QString& cueId) { m_qLabCue = cueId; } + [[nodiscard]] QStringList tags() const { return m_tags; } void setTags(const QStringList& tags) { m_tags = tags; } void addTag(const QString& tag) { @@ -146,6 +157,19 @@ class Cue { [[nodiscard]] QVariant parameter(const QString& path) const; void clearParameters() { m_parameters = QJsonObject(); } + // per-channel active actor-profile slot (channel -> slot id). On fire, the + // active actor on each channel has its stored voice for that slot applied. + [[nodiscard]] QMap channelProfiles() const { return m_channelProfiles; } + void setChannelProfiles(const QMap& profiles) { m_channelProfiles = profiles; } + 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) + [[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 removeChannelLevel(int channel) { m_channelLevels.remove(channel); } + QJsonObject toJson() const; [[nodiscard]] static Cue fromJson(const QJsonObject& json); @@ -163,6 +187,9 @@ class Cue { double m_autoFollowDelay; AutoFollowCondition m_autoFollowCondition; + double m_fadeTime = 0.0; // fade duration (seconds); 0 = instant + FadeCurve m_fadeCurve = FadeCurve::EaseInOut; + // DCA targeting QSet m_targetedDCAs; // empty = all DCAs QMap m_dcaOverrides; // per-DCA mute/label overrides @@ -181,8 +208,12 @@ class Cue { QString m_group; QStringList m_tags; + QString m_qLabCue; // linked QLab cue id (DAW remote) QJsonObject m_parameters; + + QMap m_channelProfiles; // channel -> active profile slot id + QMap m_channelLevels; // channel -> fader level override (0..1) }; } // namespace OpenMix diff --git a/src/core/FadeCurve.h b/src/core/FadeCurve.h new file mode 100644 index 0000000..82081fb --- /dev/null +++ b/src/core/FadeCurve.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +namespace OpenMix { + +enum class FadeCurve { Linear, EaseInOut, EaseIn, EaseOut }; + +inline QString fadeCurveToString(FadeCurve curve) { + switch (curve) { + case FadeCurve::Linear: + return "linear"; + case FadeCurve::EaseInOut: + return "easeInOut"; + case FadeCurve::EaseIn: + return "easeIn"; + case FadeCurve::EaseOut: + return "easeOut"; + } + return "linear"; +} + +inline FadeCurve stringToFadeCurve(const QString& str) { + if (str == "easeInOut") + return FadeCurve::EaseInOut; + if (str == "easeIn") + return FadeCurve::EaseIn; + if (str == "easeOut") + return FadeCurve::EaseOut; + return FadeCurve::Linear; +} + +} // namespace OpenMix diff --git a/src/core/FadeEngine.cpp b/src/core/FadeEngine.cpp new file mode 100644 index 0000000..1f0e610 --- /dev/null +++ b/src/core/FadeEngine.cpp @@ -0,0 +1,106 @@ +#include "FadeEngine.h" + +#include + +namespace OpenMix { + +FadeEngine::FadeEngine(QObject* parent) : QObject(parent) { + m_timer.setTimerType(Qt::PreciseTimer); + connect(&m_timer, &QTimer::timeout, this, &FadeEngine::onTick); +} + +double FadeEngine::ease(FadeCurve curve, double t) { + t = std::clamp(t, 0.0, 1.0); + switch (curve) { + case FadeCurve::Linear: + return t; + case FadeCurve::EaseInOut: + return t * t * (3.0 - 2.0 * t); // smoothstep + case FadeCurve::EaseIn: + return t * t; + case FadeCurve::EaseOut: + return 1.0 - (1.0 - t) * (1.0 - t); + } + return t; +} + +void FadeEngine::setTickIntervalMs(int ms) { m_tickIntervalMs = std::max(1, ms); } + +void FadeEngine::start(const QString& key, double from, double to, double durationMs, + FadeCurve curve, ApplyFn apply) { + cancel(key); // restart any in-flight fade for this key + + if (!apply) + return; + + if (durationMs <= 0.0) { + apply(to); + emit fadeCompleted(key); + return; + } + + apply(from); + m_fades.append(Fade{key, from, to, durationMs, 0.0, curve, std::move(apply)}); + ensureTimerRunning(); +} + +void FadeEngine::cancel(const QString& key) { + for (int i = 0; i < m_fades.size(); ++i) { + if (m_fades[i].key == key) { + m_fades.removeAt(i); + break; + } + } + if (m_fades.isEmpty()) + m_timer.stop(); +} + +void FadeEngine::cancelAll() { + m_fades.clear(); + m_timer.stop(); +} + +void FadeEngine::ensureTimerRunning() { + if (!m_timer.isActive()) { + m_clock.restart(); + m_timer.start(m_tickIntervalMs); + } +} + +void FadeEngine::onTick() { + double dt = static_cast(m_clock.restart()); + advance(dt); +} + +void FadeEngine::advance(double dtMs) { + if (m_fades.isEmpty()) + return; + + QStringList completedKeys; + + for (int i = 0; i < m_fades.size();) { + Fade& f = m_fades[i]; + f.elapsedMs += dtMs; + double t = std::clamp(f.elapsedMs / f.durationMs, 0.0, 1.0); + double value = f.from + (f.to - f.from) * ease(f.curve, t); + f.apply(value); + + if (t >= 1.0) { + completedKeys.append(f.key); + m_fades.removeAt(i); + } else { + ++i; + } + } + + if (m_fades.isEmpty()) + m_timer.stop(); + + for (const QString& key : completedKeys) + emit fadeCompleted(key); + + if (m_fades.isEmpty() && !completedKeys.isEmpty()) + emit allCompleted(); +} + +} // namespace OpenMix diff --git a/src/core/FadeEngine.h b/src/core/FadeEngine.h new file mode 100644 index 0000000..0cbe423 --- /dev/null +++ b/src/core/FadeEngine.h @@ -0,0 +1,70 @@ +#pragma once + +#include "FadeCurve.h" +#include +#include +#include +#include +#include +#include + +namespace OpenMix { + +// Drives time-based parameter fades. Each fade interpolates a value from->to over +// a duration with an easing curve, calling an apply callback every tick. The core +// stepping lives in advance(dtMs) so it can be unit-tested deterministically +// without spinning an event loop; a QTimer feeds real elapsed time in production. +class FadeEngine : public QObject { + Q_OBJECT + + public: + using ApplyFn = std::function; + + explicit FadeEngine(QObject* parent = nullptr); + + // start (or restart, if key already fading) a fade. durationMs <= 0 applies + // the target immediately. The 'from' value is applied right away. + void start(const QString& key, double from, double to, double durationMs, FadeCurve curve, + ApplyFn apply); + + void cancel(const QString& key); + void cancelAll(); + + [[nodiscard]] bool isActive() const { return !m_fades.isEmpty(); } + [[nodiscard]] int activeCount() const { return m_fades.size(); } + + void setTickIntervalMs(int ms); + + // advance every active fade by dtMs, applying interpolated values. Completed + // fades apply their exact target, emit fadeCompleted, and are removed. + void advance(double dtMs); + + static double ease(FadeCurve curve, double t); + + signals: + void fadeCompleted(const QString& key); + void allCompleted(); + + private slots: + void onTick(); + + private: + struct Fade { + QString key; + double from; + double to; + double durationMs; + double elapsedMs; + FadeCurve curve; + ApplyFn apply; + }; + + void ensureTimerRunning(); + + QVector m_fades; + QTimer m_timer; + QElapsedTimer m_clock; + int m_tickIntervalMs = 20; +}; + +} // namespace OpenMix diff --git a/src/core/PlaybackEngine.cpp b/src/core/PlaybackEngine.cpp index 4091090..852fc9a 100644 --- a/src/core/PlaybackEngine.cpp +++ b/src/core/PlaybackEngine.cpp @@ -1,4 +1,6 @@ #include "PlaybackEngine.h" +#include "ActorProfile.h" +#include "ActorProfileLibrary.h" #include "CueList.h" #include "DCAMapping.h" #include "PlaybackGuard.h" @@ -6,6 +8,8 @@ #include "protocol/MixerProtocol.h" #include #include +#include +#include namespace OpenMix { @@ -26,6 +30,8 @@ void PlaybackEngine::setMixer(MixerProtocol* mixer) { m_mixer = mixer; } void PlaybackEngine::setDCAMapping(DCAMapping* mapping) { m_dcaMapping = mapping; } +void PlaybackEngine::setActorLibrary(ActorProfileLibrary* library) { m_actorLibrary = library; } + void PlaybackEngine::setValidator(CueValidator* validator) { m_validator = validator; } void PlaybackEngine::setGuard(PlaybackGuard* guard) { m_guard = guard; } @@ -75,6 +81,9 @@ void PlaybackEngine::go() { setCurrentIndex(m_standbyIndex); emit cueExecuted(m_currentIndex); + if (cue.type() == CueType::Snapshot) + verifyCue(m_currentIndex, cue); + advanceStandby(); } @@ -143,6 +152,9 @@ void PlaybackEngine::executeCue(int index) { setCurrentIndex(index); emit cueExecuted(index); + if (cue.type() == CueType::Snapshot) + verifyCue(index, cue); + if (index + 1 < m_cueList->count()) { setStandbyIndex(index + 1); } @@ -205,6 +217,12 @@ void PlaybackEngine::executeCueInternal(const Cue& cue) { filteredCue.setParameters(filteredParams); m_mixer->recallSnapshot(filteredCue); + // apply each channel's active actor-voice profile on top + expandChannelProfiles(cue); + + // apply per-channel fader levels (faded over cue.fadeTime if set) + applyChannelLevels(cue); + // apply DCA overrides (mute & label only) applyDCAOverrides(cue, targetDCAs); @@ -262,6 +280,115 @@ void PlaybackEngine::applyDCAOverrides(const Cue& cue, const QSet& targetDC } } +void PlaybackEngine::expandChannelProfiles(const Cue& cue) { + if (!m_mixer) + return; + + // apply each channel's active actor-profile voice (gain/HPF/EQ/dynamics). + // the active actor on the channel is resolved live, so an understudy swap + // changes the applied voice without touching the cue. + if (m_actorLibrary) { + const QMap profiles = cue.channelProfiles(); + for (auto it = profiles.constBegin(); it != profiles.constEnd(); ++it) { + std::optional voice = m_actorLibrary->voiceFor(it.key(), it.value()); + if (voice.has_value()) + applyVoice(it.key(), *voice); + } + } +} + +void PlaybackEngine::applyChannelLevels(const Cue& cue) { + if (!m_mixer) + return; + + const double fadeMs = cue.fadeTime() * 1000.0; + const FadeCurve curve = cue.fadeCurve(); + const QMap levels = cue.channelLevels(); + + for (auto it = levels.constBegin(); it != levels.constEnd(); ++it) { + const int channel = it.key(); + const double target = it.value(); + 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, + [this, channel](double v) { + if (m_mixer) + m_mixer->setChannelFader(channel, v); + }); + } else { + m_mixer->setChannelFader(channel, target); + } + m_appliedChannelLevels[channel] = target; + } +} + +void PlaybackEngine::applyVoice(int channel, const VoiceData& voice) { + if (voice.gainDb.has_value()) + m_mixer->setChannelPreamp(channel, *voice.gainDb); + + if (voice.hpfOn.has_value() || voice.hpfFreq.has_value()) + m_mixer->setChannelHpf(channel, voice.hpfOn.value_or(true), voice.hpfFreq.value_or(100.0)); + + if (voice.eqOn.has_value()) + m_mixer->setChannelEqOn(channel, *voice.eqOn); + for (const EqBand& b : voice.eqBands) + m_mixer->setChannelEqBand(channel, b.band, b.on, b.type, b.freq, b.gain, b.q); + + if (voice.dynOn.has_value()) + m_mixer->setChannelDynamics(channel, *voice.dynOn, voice.dynThreshold.value_or(-20.0), + voice.dynRatio.value_or(2.0), voice.dynAttack.value_or(10.0), + voice.dynRelease.value_or(100.0), voice.dynGain.value_or(0.0)); +} + +void PlaybackEngine::verifyCue(int index, const Cue& cue) { + if (!m_verifyCues || !m_mixer || !m_mixer->isConnected()) + return; + + // send-only drivers can't read state back; treat as assumed-landed. + if (!m_mixer->supportsParameterFeedback()) + return; + + // verify the generic snapshot params (sent instantly via recallSnapshot). + const QSet targetDCAs = cue.targetsAllDCAs() ? allDCAs() : cue.targetedDCAs(); + const QJsonObject params = filterParametersForDCAs(cue, targetDCAs); + + // only numeric params (faders/levels/toggles) are verifiable by value; + // names/colours are skipped. + QStringList toVerify; + for (auto it = params.begin(); it != params.end(); ++it) { + if (it.value().isDouble()) + toVerify.append(it.key()); + } + + if (toVerify.isEmpty()) { + emit cueLanded(index); + return; + } + + struct Accumulator { + int remaining; + int index; + QStringList drifted; + }; + auto acc = std::make_shared(Accumulator{static_cast(toVerify.size()), index, {}}); + + for (const QString& path : toVerify) { + const double expected = params.value(path).toDouble(); + m_mixer->requestParameterAsync( + path, [this, acc, expected](const QString& p, const QVariant& value, bool success) { + if (!success || std::abs(value.toDouble() - expected) > 0.01) + acc->drifted.append(p); + if (--acc->remaining == 0) { + if (acc->drifted.isEmpty()) + emit cueLanded(acc->index); + else + emit cueDrifted(acc->index, acc->drifted); + } + }); + } +} + QList PlaybackEngine::getChannelsForDCA(const Cue& cue, int dca) const { // check cue-specific mapping first if (cue.hasCustomDCAMapping()) { @@ -293,7 +420,10 @@ QJsonObject PlaybackEngine::filterParametersForDCAs(const Cue& cue, QJsonObject filtered; static QRegularExpression dcaFaderRegex("^/dca/\\d+/fader$"); - for (auto it = cue.parameters().begin(); it != cue.parameters().end(); ++it) { + // bind to a local: parameters() returns by value and QJsonObject::iterator + // holds a back-pointer to the object, so iterating the temporary dangles. + const QJsonObject params = cue.parameters(); + for (auto it = params.begin(); it != params.end(); ++it) { if (!dcaFaderRegex.match(it.key()).hasMatch()) { filtered[it.key()] = it.value(); } @@ -323,7 +453,7 @@ QJsonObject PlaybackEngine::filterParametersForDCAs(const Cue& cue, static QRegularExpression dcaFaderRegex("^/dca/\\d+/fader$"); - const QJsonObject& params = cue.parameters(); + const QJsonObject params = cue.parameters(); for (auto it = params.begin(); it != params.end(); ++it) { const QString& path = it.key(); diff --git a/src/core/PlaybackEngine.h b/src/core/PlaybackEngine.h index 63469cc..0c84b51 100644 --- a/src/core/PlaybackEngine.h +++ b/src/core/PlaybackEngine.h @@ -1,7 +1,9 @@ #pragma once #include "CueValidator.h" +#include "FadeEngine.h" #include +#include #include #include #include @@ -14,6 +16,8 @@ class DCAMapping; class MixerProtocol; class PlaybackGuard; class PlaybackLogger; +class ActorProfileLibrary; +struct VoiceData; enum class PlaybackState { Stopped, Running }; @@ -26,6 +30,15 @@ class PlaybackEngine : public QObject { void setCueList(CueList* cueList); void setMixer(MixerProtocol* mixer); void setDCAMapping(DCAMapping* mapping); + void setActorLibrary(ActorProfileLibrary* library); + + // drives timed fader fades; exposed so the UI (and tests) can observe/step it + [[nodiscard]] FadeEngine* fadeEngine() { return &m_fadeEngine; } + + // when enabled, after a cue applies, read changed params back from the console + // and emit cueLanded / cueDrifted (no-op on send-only drivers) + void setVerifyCues(bool enabled) { m_verifyCues = enabled; } + [[nodiscard]] bool verifyCues() const noexcept { return m_verifyCues; } void setValidator(CueValidator* validator); [[nodiscard]] CueValidator* validator() const noexcept { return m_validator; } @@ -70,6 +83,8 @@ class PlaybackEngine : public QObject { void standbyCueChanged(int index); void cueExecuted(int index); void cueCompleted(int index); + void cueLanded(int index); + void cueDrifted(int index, const QStringList& paths); void autoFollowArmed(bool armed); void macroChildExecuted(const QString& parentId, const QString& childId); @@ -98,9 +113,20 @@ class PlaybackEngine : public QObject { QJsonObject filterParametersForDCAs(const Cue& cue, const QSet& targetDCAs) const; QSet allDCAs() const; + // actor voice profiles: apply each channel's active profile + level on fire + void expandChannelProfiles(const Cue& cue); + void applyVoice(int channel, const VoiceData& voice); + void applyChannelLevels(const Cue& cue); // fade-aware per-channel fader moves + void verifyCue(int index, const Cue& cue); + CueList* m_cueList = nullptr; MixerProtocol* m_mixer = nullptr; DCAMapping* m_dcaMapping = nullptr; + ActorProfileLibrary* m_actorLibrary = nullptr; + + FadeEngine m_fadeEngine; + QMap m_appliedChannelLevels; // last-applied fader per channel (fade source) + bool m_verifyCues = false; PlaybackState m_state = PlaybackState::Stopped; int m_currentIndex = -1; int m_standbyIndex = -1; diff --git a/src/core/Show.cpp b/src/core/Show.cpp index 3597780..44927fc 100644 --- a/src/core/Show.cpp +++ b/src/core/Show.cpp @@ -20,9 +20,11 @@ MixerConfig MixerConfig::fromJson(const QJsonObject& json) { return config; } -Show::Show(QObject* parent) : QObject(parent), m_cueList(this), m_dcaMapping(this) { +Show::Show(QObject* parent) + : QObject(parent), m_cueList(this), m_dcaMapping(this), m_actorProfileLibrary(this) { connectCueListSignals(); connectDcaMappingSignals(); + connectActorLibrarySignals(); newShow(); } @@ -63,6 +65,10 @@ void Show::connectDcaMappingSignals() { connect(&m_dcaMapping, &DCAMapping::mappingCleared, this, &Show::checkModifiedState); } +void Show::connectActorLibrarySignals() { + connect(&m_actorProfileLibrary, &ActorProfileLibrary::changed, this, &Show::checkModifiedState); +} + void Show::newShow() { m_name = tr("Untitled Show"); m_author.clear(); @@ -73,22 +79,29 @@ void Show::newShow() { m_mixerConfig.port = 10023; m_cueList.clear(); m_dcaMapping.clear(); + m_actorProfileLibrary.clear(); m_isDirty = false; } QJsonObject Show::toJson() const { QJsonObject json; - json["version"] = "1.1"; + json["version"] = "1.2"; json["name"] = m_name; json["author"] = m_author; json["notes"] = m_notes; json["mixer"] = m_mixerConfig.toJson(); json["cues"] = m_cueList.toJson(); json["dcaMapping"] = m_dcaMapping.toJson(); + json["actors"] = m_actorProfileLibrary.toJson(); return json; } void Show::fromJson(const QJsonObject& json) { + // version is read for forward/backward compat; 1.0/1.1 shows simply lack the + // newer sections and fall back to cleared defaults below. + const QString version = json["version"].toString("1.0"); + Q_UNUSED(version); + m_name = json["name"].toString("Untitled Show"); m_author = json["author"].toString(); m_notes = json["notes"].toString(); @@ -101,6 +114,13 @@ void Show::fromJson(const QJsonObject& json) { m_dcaMapping.clear(); } + // actor profile library (added in show version 1.2) + if (json.contains("actors")) { + m_actorProfileLibrary.loadFromJson(json["actors"].toObject()); + } else { + m_actorProfileLibrary.clear(); + } + m_isDirty = false; emit nameChanged(m_name); } diff --git a/src/core/Show.h b/src/core/Show.h index 7180e26..d586b0b 100644 --- a/src/core/Show.h +++ b/src/core/Show.h @@ -1,5 +1,6 @@ #pragma once +#include "ActorProfileLibrary.h" #include "CueList.h" #include "DCAMapping.h" #include @@ -49,6 +50,11 @@ class Show : public QObject { [[nodiscard]] DCAMapping* dcaMapping() { return &m_dcaMapping; } [[nodiscard]] const DCAMapping* dcaMapping() const { return &m_dcaMapping; } + [[nodiscard]] ActorProfileLibrary* actorProfileLibrary() { return &m_actorProfileLibrary; } + [[nodiscard]] const ActorProfileLibrary* actorProfileLibrary() const { + return &m_actorProfileLibrary; + } + [[nodiscard]] MixerConfig mixerConfig() const { return m_mixerConfig; } void setMixerConfig(const MixerConfig& config) { m_mixerConfig = config; checkModifiedState(); } @@ -64,6 +70,7 @@ class Show : public QObject { private: void connectCueListSignals(); void connectDcaMappingSignals(); + void connectActorLibrarySignals(); QString m_name; QString m_author; @@ -72,6 +79,7 @@ class Show : public QObject { CueList m_cueList; MixerConfig m_mixerConfig; DCAMapping m_dcaMapping; + ActorProfileLibrary m_actorProfileLibrary; bool m_isDirty = false; }; diff --git a/src/midi/MidiInputManager.cpp b/src/midi/MidiInputManager.cpp index b20d53c..7c30b7a 100644 --- a/src/midi/MidiInputManager.cpp +++ b/src/midi/MidiInputManager.cpp @@ -1,4 +1,5 @@ #include "MidiInputManager.h" +#include "MscParser.h" #include "core/PlaybackEngine.h" #include "core/PlaybackGuard.h" @@ -61,7 +62,8 @@ bool MidiInputManager::openDevice(int deviceIndex) { try { m_midiIn->openPort(deviceIndex); m_midiIn->setCallback(&MidiInputManager::midiCallback, this); - m_midiIn->ignoreTypes(true, true, true); // ignore sysex, timing, active sensing + // receive sysex (for MIDI Show Control); still ignore timing + active sensing + m_midiIn->ignoreTypes(false, true, true); m_currentDeviceName = QString::fromStdString(m_midiIn->getPortName(deviceIndex)); m_savedDeviceName = m_currentDeviceName; @@ -240,6 +242,12 @@ void MidiInputManager::processMidiMessage(const std::vector& mess if (message.size() < 2) return; + // System Exclusive — MIDI Show Control frames drive the playback engine + if (message[0] == 0xF0) { + handleSysex(message); + return; + } + unsigned char status = message[0]; int channel = status & 0x0F; int statusType = status & 0xF0; @@ -296,6 +304,39 @@ void MidiInputManager::processMidiMessage(const std::vector& mess } } +void MidiInputManager::handleSysex(const std::vector& message) { + if (!m_enabled || !m_mscEnabled || !m_engine) + return; + + const MscMessage msc = parseMsc(message); + if (!msc.valid) + return; + + // device-id filter: 0x7F = "all devices" + if (m_mscDeviceId != 0x7F && msc.deviceId != 0x7F && msc.deviceId != m_mscDeviceId) + return; + + switch (msc.command) { + case Msc::GO: + case Msc::TIMED_GO: // timed-go is fired immediately (no fade-from-MSC support yet) + if (msc.cueNumber.has_value()) + m_engine->goToNumber(*msc.cueNumber); + m_engine->go(); + break; + case Msc::STOP: + case Msc::ALL_OFF: + m_engine->stop(); + break; + case Msc::RESUME: + m_engine->go(); + break; + default: + break; + } + + emit mscReceived(msc.command); +} + void MidiInputManager::executeAction(MidiAction action) { if (action == MidiAction::None) return; diff --git a/src/midi/MidiInputManager.h b/src/midi/MidiInputManager.h index 7444a7c..b01ee09 100644 --- a/src/midi/MidiInputManager.h +++ b/src/midi/MidiInputManager.h @@ -44,6 +44,12 @@ class MidiInputManager : public QObject { void setEnabled(bool enabled); bool isEnabled() const { return m_enabled; } + // MIDI Show Control (cue GO/STOP from incoming sysex) + void setMscEnabled(bool enabled) { m_mscEnabled = enabled; } + bool isMscEnabled() const { return m_mscEnabled; } + void setMscDeviceId(int id) { m_mscDeviceId = id; } // 0-127; 0x7F = all devices + int mscDeviceId() const { return m_mscDeviceId; } + // mapping management void setMappings(const QVector& mappings); QVector mappings() const { return m_mappings; } @@ -70,6 +76,7 @@ class MidiInputManager : public QObject { void midiMessageReceived(MidiMessageType type, int channel, int noteOrCC, int value); void midiLearnReceived(const MidiTrigger& trigger); void actionExecuted(MidiAction action); + void mscReceived(int command); // a MIDI Show Control command was handled private slots: void onDevicePollTimer(); @@ -77,6 +84,7 @@ class MidiInputManager : public QObject { private: void processMidiMessage(const std::vector& message); + void handleSysex(const std::vector& message); void executeAction(MidiAction action); bool tryReconnect(); int findDeviceIndex(const QString& deviceName) const; @@ -90,6 +98,8 @@ class MidiInputManager : public QObject { bool m_enabled = true; bool m_autoReconnect = true; + bool m_mscEnabled = true; + int m_mscDeviceId = 0x7F; // 0x7F = respond to all device IDs QVector m_mappings; diff --git a/src/midi/MscParser.h b/src/midi/MscParser.h new file mode 100644 index 0000000..d465f5e --- /dev/null +++ b/src/midi/MscParser.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include +#include + +namespace OpenMix { + +// MIDI Show Control command numbers (subset relevant to cue playback). +namespace Msc { +constexpr int GO = 0x01; +constexpr int STOP = 0x02; +constexpr int RESUME = 0x03; +constexpr int TIMED_GO = 0x04; +constexpr int ALL_OFF = 0x0B; +} // namespace Msc + +struct MscMessage { + bool valid = false; + int deviceId = 0; + int command = 0; + std::optional cueNumber; // present when the GO/STOP names a cue +}; + +// Parse a System Exclusive byte stream as a MIDI Show Control message. +// Frame: F0 7F 02 [cueNumber ASCII] ... F7 +// The cue number, when present, is ASCII digits/'.' terminated by 0x00 or F7. +inline MscMessage parseMsc(const std::vector& m) { + MscMessage r; + if (m.size() < 6 || m[0] != 0xF0 || m[1] != 0x7F || m[3] != 0x02) + return r; + + r.deviceId = m[2]; + r.command = m[5]; + + QString cueText; + for (std::size_t i = 6; i < m.size(); ++i) { + unsigned char b = m[i]; + if (b == 0x00 || b == 0xF7) + break; + if ((b >= '0' && b <= '9') || b == '.') + cueText.append(QChar(b)); + else + break; + } + if (!cueText.isEmpty()) { + bool ok = false; + double value = cueText.toDouble(&ok); + if (ok) + r.cueNumber = value; + } + + r.valid = true; + return r; +} + +} // namespace OpenMix diff --git a/src/protocol/LoopbackProtocol.cpp b/src/protocol/LoopbackProtocol.cpp index 6b3faa2..2bd859b 100644 --- a/src/protocol/LoopbackProtocol.cpp +++ b/src/protocol/LoopbackProtocol.cpp @@ -177,4 +177,49 @@ void LoopbackProtocol::refresh() { // do nothing } +void LoopbackProtocol::setChannelFader(int channel, double level) { + m_recordedCalls.append(QString("fader:ch=%1:level=%2").arg(channel).arg(level)); +} + +void LoopbackProtocol::setChannelMute(int channel, bool muted) { + m_recordedCalls.append(QString("mute:ch=%1:muted=%2").arg(channel).arg(muted ? 1 : 0)); +} + +void LoopbackProtocol::setChannelPreamp(int channel, double gainDb) { + m_recordedCalls.append(QString("preamp:ch=%1:gain=%2").arg(channel).arg(gainDb)); +} + +void LoopbackProtocol::setChannelHpf(int channel, bool on, double freqHz) { + m_recordedCalls.append( + QString("hpf:ch=%1:on=%2:freq=%3").arg(channel).arg(on ? 1 : 0).arg(freqHz)); +} + +void LoopbackProtocol::setChannelEqOn(int channel, bool on) { + m_recordedCalls.append(QString("eqon:ch=%1:on=%2").arg(channel).arg(on ? 1 : 0)); +} + +void LoopbackProtocol::setChannelEqBand(int channel, int band, bool on, int type, double freqHz, + double gainDb, double q) { + m_recordedCalls.append(QString("eqband:ch=%1:band=%2:on=%3:type=%4:f=%5:g=%6:q=%7") + .arg(channel) + .arg(band) + .arg(on ? 1 : 0) + .arg(type) + .arg(freqHz) + .arg(gainDb) + .arg(q)); +} + +void LoopbackProtocol::setChannelDynamics(int channel, bool on, double thresholdDb, double ratio, + double attackMs, double releaseMs, double makeupDb) { + m_recordedCalls.append(QString("dyn:ch=%1:on=%2:thr=%3:ratio=%4:atk=%5:rel=%6:gain=%7") + .arg(channel) + .arg(on ? 1 : 0) + .arg(thresholdDb) + .arg(ratio) + .arg(attackMs) + .arg(releaseMs) + .arg(makeupDb)); +} + } // namespace OpenMix diff --git a/src/protocol/LoopbackProtocol.h b/src/protocol/LoopbackProtocol.h index d4fccb4..e5d869b 100644 --- a/src/protocol/LoopbackProtocol.h +++ b/src/protocol/LoopbackProtocol.h @@ -3,6 +3,7 @@ #include "MixerCapabilities.h" #include "MixerProtocol.h" #include +#include namespace OpenMix { @@ -41,6 +42,20 @@ class LoopbackProtocol : public MixerProtocol { // scene recall void recallScene(int sceneNumber) override; + // semantic channel setters; recorded as readable strings for testing + void setChannelFader(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; + void setChannelEqOn(int channel, bool on) override; + void setChannelEqBand(int channel, int band, bool on, int type, double freqHz, double gainDb, + double q) override; + void setChannelDynamics(int channel, bool on, double thresholdDb, double ratio, double attackMs, + double releaseMs, double makeupDb) override; + + [[nodiscard]] QStringList recordedCalls() const { return m_recordedCalls; } + void clearRecordedCalls() { m_recordedCalls.clear(); } + // keep-alive void refresh() override; @@ -49,6 +64,7 @@ class LoopbackProtocol : public MixerProtocol { // capabilities [[nodiscard]] const MixerCapabilities& capabilities() const override { return m_capabilities; } + [[nodiscard]] bool supportsParameterFeedback() const override { return true; } private: void initializeDefaultState(); @@ -58,6 +74,7 @@ class LoopbackProtocol : public MixerProtocol { ConnectionState m_connectionState = ConnectionState::Disconnected; QString m_statusMessage; QMap m_parameterState; + QStringList m_recordedCalls; }; } // namespace OpenMix diff --git a/src/protocol/MixerCapabilities.cpp b/src/protocol/MixerCapabilities.cpp index 76dda7d..8e35769 100644 --- a/src/protocol/MixerCapabilities.cpp +++ b/src/protocol/MixerCapabilities.cpp @@ -214,7 +214,7 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { caps.protocol = ProtocolType::OscUdp; caps.displayName = "Yamaha TF1"; caps.protocolId = "tf1"; - caps.defaultPort = 8000; + caps.defaultPort = 49280; caps.dcaCount = 8; caps.inputChannels = 16; caps.mixBuses = 20; @@ -233,7 +233,7 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { caps.protocol = ProtocolType::OscUdp; caps.displayName = "Yamaha TF3"; caps.protocolId = "tf3"; - caps.defaultPort = 8000; + caps.defaultPort = 49280; caps.dcaCount = 8; caps.inputChannels = 24; caps.mixBuses = 20; @@ -252,7 +252,7 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { caps.protocol = ProtocolType::OscUdp; caps.displayName = "Yamaha TF5"; caps.protocolId = "tf5"; - caps.defaultPort = 8000; + caps.defaultPort = 49280; caps.dcaCount = 8; caps.inputChannels = 32; caps.mixBuses = 20; @@ -271,7 +271,7 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { caps.protocol = ProtocolType::OscUdp; caps.displayName = "Yamaha QL1"; caps.protocolId = "ql1"; - caps.defaultPort = 8000; + caps.defaultPort = 49280; caps.dcaCount = 8; caps.inputChannels = 32; caps.mixBuses = 16; @@ -291,7 +291,7 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { caps.protocol = ProtocolType::OscUdp; caps.displayName = "Yamaha QL5"; caps.protocolId = "ql5"; - caps.defaultPort = 8000; + caps.defaultPort = 49280; caps.dcaCount = 8; caps.inputChannels = 64; caps.mixBuses = 16; @@ -311,7 +311,7 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { caps.protocol = ProtocolType::OscUdp; caps.displayName = "Yamaha CL1"; caps.protocolId = "cl1"; - caps.defaultPort = 8000; + caps.defaultPort = 49280; caps.dcaCount = 16; caps.inputChannels = 48; caps.mixBuses = 24; @@ -330,7 +330,7 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { caps.protocol = ProtocolType::OscUdp; caps.displayName = "Yamaha CL3"; caps.protocolId = "cl3"; - caps.defaultPort = 8000; + caps.defaultPort = 49280; caps.dcaCount = 16; caps.inputChannels = 64; caps.mixBuses = 24; @@ -349,7 +349,7 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { caps.protocol = ProtocolType::OscUdp; caps.displayName = "Yamaha CL5"; caps.protocolId = "cl5"; - caps.defaultPort = 8000; + caps.defaultPort = 49280; caps.dcaCount = 16; caps.inputChannels = 72; caps.mixBuses = 24; @@ -368,7 +368,7 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { caps.protocol = ProtocolType::OscUdp; caps.displayName = "Yamaha DM7"; caps.protocolId = "dm7"; - caps.defaultPort = 8000; + caps.defaultPort = 49280; caps.dcaCount = 24; caps.inputChannels = 120; caps.mixBuses = 48; diff --git a/src/protocol/MixerProtocol.cpp b/src/protocol/MixerProtocol.cpp index bc89927..be2a23c 100644 --- a/src/protocol/MixerProtocol.cpp +++ b/src/protocol/MixerProtocol.cpp @@ -8,4 +8,14 @@ const MixerCapabilities& MixerProtocol::capabilities() const { return defaultCaps; } +// 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::setChannelMute(int, bool) {} +void MixerProtocol::setChannelPreamp(int, double) {} +void MixerProtocol::setChannelHpf(int, bool, double) {} +void MixerProtocol::setChannelEqOn(int, bool) {} +void MixerProtocol::setChannelEqBand(int, int, bool, int, double, double, double) {} +void MixerProtocol::setChannelDynamics(int, bool, double, double, double, double, double) {} + } // namespace OpenMix diff --git a/src/protocol/MixerProtocol.h b/src/protocol/MixerProtocol.h index f23b1f5..3523fba 100644 --- a/src/protocol/MixerProtocol.h +++ b/src/protocol/MixerProtocol.h @@ -39,10 +39,30 @@ class MixerProtocol : public QObject { virtual void recallSnapshot(const Cue& cue) = 0; virtual void recallScene(int sceneNumber) = 0; + + // 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); + virtual void setChannelMute(int channel, bool muted); + virtual void setChannelPreamp(int channel, double gainDb); + virtual void setChannelHpf(int channel, bool on, double freqHz); + virtual void setChannelEqOn(int channel, bool on); + virtual void setChannelEqBand(int channel, int band, bool on, int type, double freqHz, + double gainDb, double q); + virtual void setChannelDynamics(int channel, bool on, double thresholdDb, double ratio, + double attackMs, double releaseMs, double makeupDb); + virtual void refresh() = 0; virtual int latencyMs() const = 0; virtual const MixerCapabilities& capabilities() const; + // true if the driver can read parameter values back from the console (so cue + // landing can be verified). Send-only drivers (e.g. Allen & Heath MIDI/ACE) + // return false and are treated as "assumed landed" rather than drifted. + [[nodiscard]] virtual bool supportsParameterFeedback() const { return false; } + signals: void connected(); void disconnected(); diff --git a/src/protocol/behringer/WingProtocol.cpp b/src/protocol/behringer/WingProtocol.cpp index d00c522..606ede2 100644 --- a/src/protocol/behringer/WingProtocol.cpp +++ b/src/protocol/behringer/WingProtocol.cpp @@ -225,6 +225,73 @@ void WingProtocol::recallScene(int sceneNumber) { m_transport.send("/action/scenes/recall", sceneNumber); } +namespace { +QString wingChannel(int channel) { return QString("/ch/%1").arg(channel); } + +// WING faders carry real-world dB; map a normalized 0..1 level onto the fader +// taper (raw 0.75 = 0 dB, 1.0 = +10 dB, 0.0 = -inf). Approximate. +float wingFaderDb(double level) { + level = std::clamp(level, 0.0, 1.0); + if (level <= 0.0) + return -144.0f; + if (level >= 0.75) + return static_cast((level - 0.75) / 0.25 * 10.0); + return static_cast(level / 0.75 * 90.0 - 90.0); +} + +// WING STD EQ exposes named bands l,1,2,3,4,h (not numbered 1..N) +QString wingEqBand(int band) { + static const char* tokens[] = {"l", "1", "2", "3", "4", "h"}; + int idx = std::clamp(band - 1, 0, 5); + return tokens[idx]; +} +} // namespace + +void WingProtocol::setChannelFader(int channel, double level) { + sendParameter(wingChannel(channel) + "/fdr", wingFaderDb(level)); +} + +void WingProtocol::setChannelMute(int channel, bool muted) { + // WING /mute polarity is the opposite of X32: 1 = muted, 0 = unmuted + sendParameter(wingChannel(channel) + "/mute", muted ? 1 : 0); +} + +void WingProtocol::setChannelPreamp(int channel, double gainDb) { + // per-channel head-amp gain (real dB), follows the patched source + sendParameter(wingChannel(channel) + "/in/set/$g", static_cast(gainDb)); +} + +void WingProtocol::setChannelHpf(int channel, bool on, double freqHz) { + // WING's HPF is the channel low-cut filter + const QString ch = wingChannel(channel); + sendParameter(ch + "/flt/lc", on ? 1 : 0); + sendParameter(ch + "/flt/lcf", static_cast(freqHz)); +} + +void WingProtocol::setChannelEqOn(int channel, bool on) { + sendParameter(wingChannel(channel) + "/eq/on", on ? 1 : 0); +} + +void WingProtocol::setChannelEqBand(int channel, int band, bool /*on*/, int /*type*/, double freqHz, + double gainDb, double q) { + // WING named bands carry real-world values: /ch/N/eq/{g,f,q} + const QString prefix = wingChannel(channel) + "/eq/" + wingEqBand(band); + sendParameter(prefix + "g", static_cast(gainDb)); + sendParameter(prefix + "f", static_cast(freqHz)); + sendParameter(prefix + "q", static_cast(q)); +} + +void WingProtocol::setChannelDynamics(int channel, bool on, double thresholdDb, double ratio, + double attackMs, double releaseMs, double makeupDb) { + const QString ch = wingChannel(channel); + sendParameter(ch + "/dyn/on", on ? 1 : 0); + sendParameter(ch + "/dyn/thr", static_cast(thresholdDb)); + sendParameter(ch + "/dyn/ratio", static_cast(ratio)); + sendParameter(ch + "/dyn/att", static_cast(attackMs)); + sendParameter(ch + "/dyn/rel", static_cast(releaseMs)); + sendParameter(ch + "/dyn/gain", static_cast(makeupDb)); +} + void WingProtocol::refresh() { if (m_connectionState != ConnectionState::Connected) return; diff --git a/src/protocol/behringer/WingProtocol.h b/src/protocol/behringer/WingProtocol.h index 4f65539..49026c5 100644 --- a/src/protocol/behringer/WingProtocol.h +++ b/src/protocol/behringer/WingProtocol.h @@ -52,6 +52,17 @@ class WingProtocol : public MixerProtocol { // scene recall void recallScene(int sceneNumber) override; + // semantic channel setters (WING uses real-world values, so most pass through) + void setChannelFader(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; + void setChannelEqOn(int channel, bool on) override; + void setChannelEqBand(int channel, int band, bool on, int type, double freqHz, double gainDb, + double q) override; + void setChannelDynamics(int channel, bool on, double thresholdDb, double ratio, double attackMs, + double releaseMs, double makeupDb) override; + // keep-alive void refresh() override; @@ -60,6 +71,7 @@ class WingProtocol : public MixerProtocol { // capabilities [[nodiscard]] const MixerCapabilities& capabilities() const override { return m_capabilities; } + [[nodiscard]] bool supportsParameterFeedback() const override { return true; } private slots: void onTransportConnected(); diff --git a/src/protocol/behringer/X32Protocol.cpp b/src/protocol/behringer/X32Protocol.cpp index ca506d3..3c1b381 100644 --- a/src/protocol/behringer/X32Protocol.cpp +++ b/src/protocol/behringer/X32Protocol.cpp @@ -2,6 +2,8 @@ #include "../../core/Cue.h" #include #include +#include +#include namespace OpenMix { @@ -9,6 +11,40 @@ namespace { constexpr int MAX_X32_INPUT_CHANNELS = 32; constexpr int MAX_X32_EFFECT_SENDS = 16; constexpr int MAX_X32_MIX_BUSES = 16; + +// X32 OSC encodes most continuous params as a normalized float 0..1 mapped onto +// the parameter's real range. These helpers convert real-world units to that +// 0..1 value. The curves are close approximations of the console's mapping; the +// exact lookup tables can refine the constants without changing call sites. +QString x32Channel(int channel) { return QString("/ch/%1").arg(channel, 2, 10, QChar('0')); } + +float clampUnit(double v) { return static_cast(std::clamp(v, 0.0, 1.0)); } + +float linNorm(double v, double lo, double hi) { return clampUnit((v - lo) / (hi - lo)); } + +float logNorm(double v, double lo, double hi) { + if (v <= lo) + return 0.0f; + if (v >= hi) + return 1.0f; + return static_cast(std::log(v / lo) / std::log(hi / lo)); +} + +// 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, + 4.0, 5.0, 7.0, 10.0, 20.0, 100.0}; + int best = 0; + double bestDiff = 1e9; + for (int i = 0; i < static_cast(ratios.size()); ++i) { + double diff = std::abs(ratios[i] - ratio); + if (diff < bestDiff) { + bestDiff = diff; + best = i; + } + } + return best; +} } // namespace X32Protocol::X32Protocol(const MixerCapabilities& caps, QObject* parent) @@ -225,6 +261,52 @@ void X32Protocol::recallScene(int sceneNumber) { m_transport.send("/-action/goscene", sceneNumber); } +void X32Protocol::setChannelFader(int channel, double level) { + sendParameter(x32Channel(channel) + "/mix/fader", clampUnit(level)); +} + +void X32Protocol::setChannelMute(int channel, bool muted) { + // /mix/on: 1 = on (unmuted), 0 = muted + sendParameter(x32Channel(channel) + "/mix/on", muted ? 0 : 1); +} + +void X32Protocol::setChannelPreamp(int channel, double gainDb) { + // per-channel digital trim, +/-18 dB (head-amp analog gain is patch-dependent) + sendParameter(x32Channel(channel) + "/preamp/trim", linNorm(gainDb, -18.0, 18.0)); +} + +void X32Protocol::setChannelHpf(int channel, bool on, double freqHz) { + const QString ch = x32Channel(channel); + sendParameter(ch + "/preamp/hpon", on ? 1 : 0); + sendParameter(ch + "/preamp/hpf", logNorm(freqHz, 20.0, 400.0)); +} + +void X32Protocol::setChannelEqOn(int channel, bool on) { + sendParameter(x32Channel(channel) + "/eq/on", on ? 1 : 0); +} + +void X32Protocol::setChannelEqBand(int channel, int band, bool /*on*/, int type, double freqHz, + double gainDb, double q) { + // X32 EQ has no per-band enable; the band's type selects its shape. + const QString prefix = QString("%1/eq/%2").arg(x32Channel(channel)).arg(band); + sendParameter(prefix + "/type", type); + sendParameter(prefix + "/f", logNorm(freqHz, 20.0, 20000.0)); + sendParameter(prefix + "/g", linNorm(gainDb, -15.0, 15.0)); + // X32 Q is descending: norm 0.0 = Q10.0, norm 1.0 = Q0.3 + sendParameter(prefix + "/q", 1.0f - logNorm(q, 0.3, 10.0)); +} + +void X32Protocol::setChannelDynamics(int channel, bool on, double thresholdDb, double ratio, + double attackMs, double releaseMs, double makeupDb) { + const QString ch = x32Channel(channel); + sendParameter(ch + "/dyn/on", on ? 1 : 0); + sendParameter(ch + "/dyn/thr", linNorm(thresholdDb, -60.0, 0.0)); + sendParameter(ch + "/dyn/ratio", x32RatioIndex(ratio)); + sendParameter(ch + "/dyn/attack", linNorm(attackMs, 0.0, 120.0)); + sendParameter(ch + "/dyn/release", logNorm(releaseMs, 5.0, 4000.0)); + sendParameter(ch + "/dyn/mgain", linNorm(makeupDb, 0.0, 24.0)); +} + void X32Protocol::refresh() { if (m_connectionState != ConnectionState::Connected) return; diff --git a/src/protocol/behringer/X32Protocol.h b/src/protocol/behringer/X32Protocol.h index 462e783..38780a5 100644 --- a/src/protocol/behringer/X32Protocol.h +++ b/src/protocol/behringer/X32Protocol.h @@ -50,6 +50,17 @@ class X32Protocol : public MixerProtocol { // scene/snapshot recall void recallScene(int sceneNumber) override; + // semantic channel setters (used by actor-voice recall and fades) + void setChannelFader(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; + void setChannelEqOn(int channel, bool on) override; + void setChannelEqBand(int channel, int band, bool on, int type, double freqHz, double gainDb, + double q) override; + void setChannelDynamics(int channel, bool on, double thresholdDb, double ratio, double attackMs, + double releaseMs, double makeupDb) override; + // keep-alive void refresh() override; @@ -58,6 +69,7 @@ class X32Protocol : public MixerProtocol { // capabilities [[nodiscard]] const MixerCapabilities& capabilities() const override { return m_capabilities; } + [[nodiscard]] bool supportsParameterFeedback() const override { return true; } // X32-specific: list of parameters to recall [[nodiscard]] QStringList snapshotParameters() const { return m_snapshotParams; } diff --git a/src/protocol/yamaha/YamahaCLProtocol.cpp b/src/protocol/yamaha/YamahaCLProtocol.cpp index 88eb5ad..9a657db 100644 --- a/src/protocol/yamaha/YamahaCLProtocol.cpp +++ b/src/protocol/yamaha/YamahaCLProtocol.cpp @@ -3,21 +3,6 @@ namespace OpenMix { YamahaCLProtocol::YamahaCLProtocol(const MixerCapabilities& caps, QObject* parent) - : YamahaProtocol(caps, parent) { - initializeSnapshotParams(); -} - -void YamahaCLProtocol::initializeSnapshotParams() { - m_snapshotParams.clear(); - - // CL series input channels (up to 72), 16 effect-send buses max - appendEqSnapshotParams(m_snapshotParams, "/ch/", 72, 2, 16); - - // CL series has 16 DCAs - for (int i = 1; i <= m_capabilities.dcaCount && i <= 16; ++i) { - m_snapshotParams.append(QString("/dca/%1/fader").arg(i)); - m_snapshotParams.append(QString("/dca/%1/on").arg(i)); - } -} + : YamahaProtocol(caps, parent) {} } // namespace OpenMix diff --git a/src/protocol/yamaha/YamahaCLProtocol.h b/src/protocol/yamaha/YamahaCLProtocol.h index ca40eb1..65a8683 100644 --- a/src/protocol/yamaha/YamahaCLProtocol.h +++ b/src/protocol/yamaha/YamahaCLProtocol.h @@ -4,18 +4,15 @@ namespace OpenMix { -// Yamaha CL series (CL1, CL3, CL5) protocol -// 16 DCAs, 48-72 input channels (model dependent), 24 mix buses, 8 matrix +// Yamaha CL series (CL1, CL3, CL5). SCP over TCP 49280. +// 16 DCAs, 48-72 input channels (model dependent), 24 mix buses, 8 matrix. class YamahaCLProtocol : public YamahaProtocol { Q_OBJECT public: explicit YamahaCLProtocol(const MixerCapabilities& caps, QObject* parent = nullptr); - [[nodiscard]] QString protocolDescription() const override { return "Yamaha CL Protocol"; } - - protected: - void initializeSnapshotParams() override; + [[nodiscard]] QString protocolDescription() const override { return "Yamaha CL SCP Protocol"; } }; } // namespace OpenMix diff --git a/src/protocol/yamaha/YamahaDM7Protocol.cpp b/src/protocol/yamaha/YamahaDM7Protocol.cpp index f86a1e2..0820e56 100644 --- a/src/protocol/yamaha/YamahaDM7Protocol.cpp +++ b/src/protocol/yamaha/YamahaDM7Protocol.cpp @@ -1,23 +1,14 @@ #include "YamahaDM7Protocol.h" +#include +#include namespace OpenMix { YamahaDM7Protocol::YamahaDM7Protocol(const MixerCapabilities& caps, QObject* parent) - : YamahaProtocol(caps, parent) { - initializeSnapshotParams(); -} - -void YamahaDM7Protocol::initializeSnapshotParams() { - m_snapshotParams.clear(); - - // DM7 input channels (up to 120), 3-digit channel field, 24 effect-send buses max - appendEqSnapshotParams(m_snapshotParams, "/ch/", 120, 3, 24); + : YamahaProtocol(caps, parent) {} - // DM7 has 24 DCAs - for (int i = 1; i <= m_capabilities.dcaCount && i <= 24; ++i) { - m_snapshotParams.append(QString("/dca/%1/fader").arg(i)); - m_snapshotParams.append(QString("/dca/%1/on").arg(i)); - } +int YamahaDM7Protocol::preampGainToScp(double gainDb) const { + return std::clamp(static_cast(std::lround(gainDb)), -6, 66); } } // namespace OpenMix diff --git a/src/protocol/yamaha/YamahaDM7Protocol.h b/src/protocol/yamaha/YamahaDM7Protocol.h index 2f54310..fd049f9 100644 --- a/src/protocol/yamaha/YamahaDM7Protocol.h +++ b/src/protocol/yamaha/YamahaDM7Protocol.h @@ -4,18 +4,20 @@ namespace OpenMix { -// Yamaha DM7 / DM7 Compact protocol -// 24 DCAs, 120 input channels, 48 mix buses, 24 matrix +// Yamaha DM7 / DM7 Compact. SCP over TCP 49280. +// 24 DCAs, 120 input channels, 48 mix buses, 24 matrix. class YamahaDM7Protocol : public YamahaProtocol { Q_OBJECT public: explicit YamahaDM7Protocol(const MixerCapabilities& caps, QObject* parent = nullptr); - [[nodiscard]] QString protocolDescription() const override { return "Yamaha DM7 Protocol"; } + [[nodiscard]] QString protocolDescription() const override { return "Yamaha DM7 SCP Protocol"; } protected: - void initializeSnapshotParams() override; + // DM7 transmits head-amp gain in whole dB (-6..+66), unlike the centi-dB + // CL/QL/Rivage use. + [[nodiscard]] int preampGainToScp(double gainDb) const override; }; } // namespace OpenMix diff --git a/src/protocol/yamaha/YamahaProtocol.cpp b/src/protocol/yamaha/YamahaProtocol.cpp index 8e69f93..a83d637 100644 --- a/src/protocol/yamaha/YamahaProtocol.cpp +++ b/src/protocol/yamaha/YamahaProtocol.cpp @@ -1,546 +1,533 @@ #include "YamahaProtocol.h" #include "../../core/Cue.h" -#include +#include #include -#include -#include +#include namespace OpenMix { YamahaProtocol::YamahaProtocol(const MixerCapabilities& caps, QObject* parent) : MixerProtocol(parent), m_capabilities(caps) { - m_receivePort = caps.defaultPort; // 8000 for Yamaha OSC receive - m_transmitPort = 9000; // Yamaha OSC transmit port - - initializeSnapshotParams(); - - QObject::connect(&m_receiveSocket, &QUdpSocket::readyRead, this, &YamahaProtocol::onReadyRead); - + QObject::connect(&m_transport, &TcpTransport::connected, this, + &YamahaProtocol::onTransportConnected); + QObject::connect(&m_transport, &TcpTransport::disconnected, this, + &YamahaProtocol::onTransportDisconnected); + QObject::connect(&m_transport, &TcpTransport::connectionError, this, + &YamahaProtocol::onTransportError); + QObject::connect(&m_transport, &TcpTransport::connectionLost, this, + &YamahaProtocol::onTransportConnectionLost); + QObject::connect(&m_transport, &TcpTransport::dataReceived, this, + &YamahaProtocol::onDataReceived); + QObject::connect(&m_transport, &TcpTransport::reconnecting, this, + &YamahaProtocol::onReconnecting); + + m_keepAliveTimer.setInterval(KEEPALIVE_INTERVAL); QObject::connect(&m_keepAliveTimer, &QTimer::timeout, this, &YamahaProtocol::onKeepAliveTimeout); - m_connectionTimer.setSingleShot(true); - QObject::connect(&m_connectionTimer, &QTimer::timeout, this, - &YamahaProtocol::onConnectionTimeout); - m_requestTimeoutTimer.setInterval(500); QObject::connect(&m_requestTimeoutTimer, &QTimer::timeout, this, &YamahaProtocol::onRequestTimeoutCheck); - - m_reconnectTimer.setSingleShot(true); - QObject::connect(&m_reconnectTimer, &QTimer::timeout, this, - &YamahaProtocol::onReconnectAttempt); } YamahaProtocol::~YamahaProtocol() { disconnect(); } -void YamahaProtocol::initializeSnapshotParams() { rebuildSnapshotParams(); } - -void YamahaProtocol::rebuildSnapshotParams() { - m_snapshotParams.clear(); - - // input channel fader/on, EQ, and effect-send parameters - appendEqSnapshotParams(m_snapshotParams, "/ch/", m_capabilities.inputChannels); - - // DCA parameters, Yamaha uses /dca/X/fader and /dca/X/on (NOT /mute) - for (int i = 1; i <= m_capabilities.dcaCount; ++i) { - m_snapshotParams.append(QString("/dca/%1/fader").arg(i)); - m_snapshotParams.append(QString("/dca/%1/on").arg(i)); - } +// -------------------------------------------------------------------------- +// Pure command framing (no I/O, no state) -- unit tested for exact strings. +// -------------------------------------------------------------------------- + +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(); } -void YamahaProtocol::appendEqSnapshotParams(QStringList& params, - const QString& channelPrefix, - int channelCount, - int channelFieldWidth, - int maxEffectSends) const { - for (int ch = 1; ch <= m_capabilities.inputChannels && ch <= channelCount; ++ch) { - QString chPrefix = - QString("%1%2").arg(channelPrefix).arg(ch, channelFieldWidth, 10, QChar('0')); - params.append(chPrefix + "/mix/fader"); - params.append(chPrefix + "/mix/on"); - - // EQ parameters - if (m_capabilities.supportsChannelEQ) { - params.append(chPrefix + "/eq/on"); - for (int band = 1; band <= m_capabilities.eqBandsPerChannel; ++band) { - QString bandPrefix = QString("%1/eq/%2").arg(chPrefix).arg(band); - params.append(bandPrefix + "/type"); - params.append(bandPrefix + "/f"); - params.append(bandPrefix + "/g"); - params.append(bandPrefix + "/q"); - } - } +QByteArray YamahaProtocol::scpSet(const QString& address, int idx1, int idx2, + const QString& value) { + return QStringLiteral("set %1 %2 %3 \"%4\"\n") + .arg(address) + .arg(idx1) + .arg(idx2) + .arg(value) + .toUtf8(); +} - // effect send parameters - if (m_capabilities.supportsEffectSends) { - int sends = std::min(m_capabilities.effectSendBuses, maxEffectSends); - for (int send = 1; send <= sends; ++send) { - QString sendPrefix = - QString("%1/mix/%2").arg(chPrefix).arg(send, 2, 10, QChar('0')); - params.append(sendPrefix + "/level"); - params.append(sendPrefix + "/on"); - } - } - } +QByteArray YamahaProtocol::scpGet(const QString& address, int idx1, int idx2) { + return QStringLiteral("get %1 %2 %3\n").arg(address).arg(idx1).arg(idx2).toUtf8(); } -bool YamahaProtocol::connect(const QString& host, int port) { - if (m_connectionState == ConnectionState::Connected || - m_connectionState == ConnectionState::Connecting) { - disconnect(); - } +QByteArray YamahaProtocol::scpSceneRecall(int sceneNumber) { + return QStringLiteral("ssrecall_ex %1 %2\n").arg(SceneLib).arg(sceneNumber).toUtf8(); +} - m_host = host; - m_port = port > 0 ? port : m_receivePort; - m_reconnectAttempts = 0; +// -------------------------------------------------------------------------- +// 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)); + return std::clamp(centi, FADER_MIN_CENTIDB, FADER_MAX_CENTIDB); +} - setConnectionState(ConnectionState::Connecting); - setStatus(QString("Connecting to %1:%2...").arg(host).arg(m_transmitPort)); - - // create OSC address for sending to transmit port - m_oscAddress = lo_address_new(host.toUtf8().constData(), - QString::number(m_transmitPort).toUtf8().constData()); - if (!m_oscAddress) { - setStatus("Failed to create OSC address"); - setConnectionState(ConnectionState::Disconnected); - emit connectionError("Failed to create OSC address"); - return false; - } +int YamahaProtocol::dbToCentiDb(double db) { return static_cast(std::lround(db * 100.0)); } - // bind receive socket to receive port - if (!m_receiveSocket.bind(QHostAddress::Any, m_port)) { - lo_address_free(m_oscAddress); - m_oscAddress = nullptr; - setStatus("Failed to bind UDP receive socket"); - setConnectionState(ConnectionState::Disconnected); - emit connectionError("Failed to bind UDP socket on port " + QString::number(m_port)); - return false; - } +int YamahaProtocol::hzToScpFreq(double hz) { return static_cast(std::lround(hz * 10.0)); } - // send model query to verify connection - m_waitingForModel = true; - sendOscMessage("/sys/model"); - m_connectionTimer.start(m_connectionTimeoutMs); +int YamahaProtocol::qToScp(double q) { return static_cast(std::lround(q * 1000.0)); } - return true; +int YamahaProtocol::preampGainToScp(double gainDb) const { + // CL/QL/Rivage: head-amp gain in centi-dB, range -6 dB..+66 dB. + return std::clamp(dbToCentiDb(gainDb), -600, 6600); } -void YamahaProtocol::disconnect() { - m_keepAliveTimer.stop(); - m_connectionTimer.stop(); - m_reconnectTimer.stop(); - m_requestTimeoutTimer.stop(); +// -------------------------------------------------------------------------- +// Semantic command builders. +// -------------------------------------------------------------------------- - if (m_oscAddress) { - lo_address_free(m_oscAddress); - m_oscAddress = nullptr; - } - - m_receiveSocket.close(); - - m_parameterCache.clear(); - m_pendingRequests.clear(); - m_latencyHistory.clear(); - m_latencyMs = 0; - m_reconnectAttempts = 0; - m_waitingForModel = false; - - setConnectionState(ConnectionState::Disconnected); - setStatus("Disconnected"); - emit disconnected(); +QByteArray YamahaProtocol::buildChannelFader(int ch, double level) const { + return scpSet(AddrFaderLevel, ch, 0, faderLevelToScp(level)); } -void YamahaProtocol::sendParameter(const QString& path, const QVariant& value) { - if (m_connectionState != ConnectionState::Connected) { - return; - } +QByteArray YamahaProtocol::buildChannelMute(int ch, bool muted) const { + // Fader/On: 1 = channel ON (unmuted), 0 = muted. + return scpSet(AddrFaderOn, ch, 0, muted ? 0 : 1); +} - switch (value.typeId()) { - case QMetaType::Float: - case QMetaType::Double: - sendOscMessage(path, value.toFloat()); - break; - case QMetaType::Int: - case QMetaType::Bool: - sendOscMessage(path, value.toInt()); - break; - case QMetaType::QString: - sendOscMessage(path, value.toString()); - break; - default: - sendOscMessage(path, value.toFloat()); - break; - } +QByteArray YamahaProtocol::buildChannelPreamp(int ch, double gainDb) const { + return scpSet(AddrHaGain, ch, 0, preampGainToScp(gainDb)); +} - m_parameterCache[path] = value; +QByteArray YamahaProtocol::buildChannelHpfOn(int ch, bool on) const { + return scpSet(AddrHpfOn, ch, 0, on ? 1 : 0); } -QVariant YamahaProtocol::getParameter(const QString& path) { return m_parameterCache.value(path); } +QByteArray YamahaProtocol::buildChannelHpfFreq(int ch, double freqHz) const { + return scpSet(AddrHpfFreq, ch, 0, hzToScpFreq(freqHz)); +} -void YamahaProtocol::requestParameter(const QString& path) { - if (m_connectionState != ConnectionState::Connected) { - return; - } +QByteArray YamahaProtocol::buildChannelEqOn(int ch, bool on) const { + return scpSet(AddrEqOn, ch, 0, on ? 1 : 0); +} - YamahaPendingRequest req; - req.path = path; - req.timestamp = QDateTime::currentDateTime(); - req.sentTime = QDateTime::currentMSecsSinceEpoch(); - req.callback = nullptr; - m_pendingRequests[path] = req; +QByteArray YamahaProtocol::buildChannelEqBandBypass(int ch, int band, bool on) const { + // Band/Bypass: 1 = bypassed, 0 = active, so "on" inverts. + return scpSet(AddrEqBandBypass, ch, band, on ? 0 : 1); +} - sendOscMessage(path); +QByteArray YamahaProtocol::buildChannelEqBandFreq(int ch, int band, double freqHz) const { + return scpSet(AddrEqBandFreq, ch, band, hzToScpFreq(freqHz)); } -void YamahaProtocol::requestParameterAsync(const QString& path, ParameterCallback callback) { - if (m_connectionState != ConnectionState::Connected) { - if (callback) { - callback(path, QVariant(), false); - } - return; - } +QByteArray YamahaProtocol::buildChannelEqBandGain(int ch, int band, double gainDb) const { + return scpSet(AddrEqBandGain, ch, band, std::clamp(dbToCentiDb(gainDb), -1800, 1800)); +} - YamahaPendingRequest req; - req.path = path; - req.timestamp = QDateTime::currentDateTime(); - req.sentTime = QDateTime::currentMSecsSinceEpoch(); - req.callback = callback; - m_pendingRequests[path] = req; +QByteArray YamahaProtocol::buildChannelEqBandQ(int ch, int band, double q) const { + return scpSet(AddrEqBandQ, ch, band, std::clamp(qToScp(q), 100, 16000)); +} - sendOscMessage(path); +QByteArray YamahaProtocol::buildChannelDynamicsThreshold(int ch, double thresholdDb) const { + // Dyna2 (compressor) threshold is deci-dB, range -54 dB..0 dB. + int deci = static_cast(std::lround(thresholdDb * 10.0)); + return scpSet(AddrDynaThreshold, ch, 0, std::clamp(deci, -540, 0)); } -void YamahaProtocol::recallSnapshot(const Cue& cue) { - if (m_connectionState != ConnectionState::Connected) { - return; - } +// -------------------------------------------------------------------------- +// Semantic setters (send when connected, and cache). +// -------------------------------------------------------------------------- - QJsonObject params = cue.parameters(); - for (const auto& [path, value] : params.asKeyValueRange()) { - sendParameter(path.toString(), value.toVariant()); - } +// 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) { + sendCommand(buildChannelFader(ch - 1, level)); } -void YamahaProtocol::recallScene(int sceneNumber) { - if (m_connectionState != ConnectionState::Connected) { - return; - } +void YamahaProtocol::setChannelMute(int ch, bool muted) { + sendCommand(buildChannelMute(ch - 1, muted)); +} - // Yamaha scene recall: /scene/recall with scene number - sendOscMessage("/scene/recall", sceneNumber); +void YamahaProtocol::setChannelPreamp(int ch, double gainDb) { + sendCommand(buildChannelPreamp(ch - 1, gainDb)); } -void YamahaProtocol::refresh() { - if (m_connectionState != ConnectionState::Connected) { - return; - } +void YamahaProtocol::setChannelHpf(int ch, bool on, double freqHz) { + sendCommand(buildChannelHpfOn(ch - 1, on)); + if (freqHz > 0.0) + sendCommand(buildChannelHpfFreq(ch - 1, freqHz)); +} - for (const QString& param : m_snapshotParams) { - requestParameter(param); - } +void YamahaProtocol::setChannelEqOn(int ch, bool on) { sendCommand(buildChannelEqOn(ch - 1, on)); } + +void YamahaProtocol::setChannelEqBand(int ch, int band, bool on, int type, double freqHz, + double gainDb, double q) { + // Yamaha PEQ bands are parametric; per-band shelf "type" is toggled by the + // console rather than addressed as a band node over SCP, so `type` is not + // transmitted by this driver. + Q_UNUSED(type); + sendCommand(buildChannelEqBandBypass(ch - 1, band, on)); + sendCommand(buildChannelEqBandFreq(ch - 1, band, freqHz)); + sendCommand(buildChannelEqBandGain(ch - 1, band, gainDb)); + sendCommand(buildChannelEqBandQ(ch - 1, band, q)); } -void YamahaProtocol::onReadyRead() { - while (m_receiveSocket.hasPendingDatagrams()) { - QNetworkDatagram datagram = m_receiveSocket.receiveDatagram(); - parseOscMessage(datagram.data()); - } +void YamahaProtocol::setChannelDynamics(int ch, bool on, double thresholdDb, double ratio, + double attackMs, double releaseMs, double makeupDb) { + // SCP exposes the compressor (Dyna2) threshold; on/off, ratio, attack, + // release and makeup are not addressable via this driver. + Q_UNUSED(on); + Q_UNUSED(ratio); + Q_UNUSED(attackMs); + Q_UNUSED(releaseMs); + Q_UNUSED(makeupDb); + sendCommand(buildChannelDynamicsThreshold(ch - 1, thresholdDb)); } -void YamahaProtocol::onKeepAliveTimeout() { - if (m_connectionState == ConnectionState::Connected) { - qint64 now = QDateTime::currentMSecsSinceEpoch(); - if (m_lastResponseTime > 0 && (now - m_lastResponseTime) > (KEEPALIVE_INTERVAL * 3)) { - setStatus("Connection lost - no response from mixer"); - emit connectionLost(); - startReconnection(); - return; - } +// -------------------------------------------------------------------------- +// Connection lifecycle. +// -------------------------------------------------------------------------- - // query model as keep-alive - sendOscMessage("/sys/model"); +bool YamahaProtocol::connect(const QString& host, int port) { + if (m_connectionState == ConnectionState::Connected || + m_connectionState == ConnectionState::Connecting) { + disconnect(); } -} -void YamahaProtocol::onConnectionTimeout() { - if (m_connectionState == ConnectionState::Connecting) { - setStatus("Connection timeout"); - emit connectionError("Connection timeout - no response from mixer"); - startReconnection(); - } else if (m_connectionState == ConnectionState::Reconnecting) { - if (m_reconnectAttempts >= m_maxReconnectAttempts) { - setStatus("Reconnection failed - max attempts reached"); - setConnectionState(ConnectionState::Disconnected); - emit connectionError("Failed to reconnect after maximum attempts"); - } else { - int shift = std::min(m_reconnectAttempts, 10); - int delay = std::min(m_reconnectDelayMs * (1 << shift), 30000); - m_reconnectTimer.start(delay); - } - } + m_host = host; + m_port = port > 0 ? port : SCP_PORT; // SCP is always 49280 + m_rxBuffer.clear(); + + setConnectionState(ConnectionState::Connecting); + setStatus(QString("Connecting to %1:%2...").arg(m_host).arg(m_port)); + + m_transport.setReconnectEnabled(true); + return m_transport.connect(m_host, m_port); // async; result via signals } -void YamahaProtocol::onRequestTimeoutCheck() { - if (m_connectionState != ConnectionState::Connected) { - return; - } +void YamahaProtocol::disconnect() { + m_keepAliveTimer.stop(); + m_requestTimeoutTimer.stop(); - QDateTime now = QDateTime::currentDateTime(); - QStringList timedOut; + m_transport.setReconnectEnabled(false); + m_transport.disconnect(); - for (const auto& [path, req] : m_pendingRequests.asKeyValueRange()) { - if (req.timestamp.msecsTo(now) > m_requestTimeoutMs) { - timedOut.append(path); - } - } + m_rxBuffer.clear(); + m_parameterCache.clear(); + m_pendingRequests.clear(); + m_latencyMs = 0; + m_lastResponseTime = 0; - for (const QString& path : timedOut) { - YamahaPendingRequest req = m_pendingRequests.take(path); - if (req.callback) { - req.callback(path, QVariant(), false); - } - emit requestTimeout(path); - } + setConnectionState(ConnectionState::Disconnected); + setStatus("Disconnected"); + emit disconnected(); } -void YamahaProtocol::onReconnectAttempt() { - if (m_reconnectAttempts >= m_maxReconnectAttempts) { - setStatus("Reconnection failed - max attempts reached"); - setConnectionState(ConnectionState::Disconnected); - emit connectionError("Failed to reconnect after maximum attempts"); - return; - } +void YamahaProtocol::onTransportConnected() { + setConnectionState(ConnectionState::Connected); + setStatus(QString("Connected to %1:%2").arg(m_host).arg(m_port)); - m_reconnectAttempts++; - setStatus(QString("Reconnecting (attempt %1/%2)...") - .arg(m_reconnectAttempts) - .arg(m_maxReconnectAttempts)); + m_lastResponseTime = QDateTime::currentMSecsSinceEpoch(); + m_keepAliveTimer.start(); + m_requestTimeoutTimer.start(); - // clean up old connection - if (m_oscAddress) { - lo_address_free(m_oscAddress); - m_oscAddress = nullptr; - } - m_receiveSocket.close(); - - // recreate connection - m_oscAddress = lo_address_new(m_host.toUtf8().constData(), - QString::number(m_transmitPort).toUtf8().constData()); - if (!m_oscAddress) { - int shift = std::min(m_reconnectAttempts - 1, 10); - int delay = std::min(m_reconnectDelayMs * (1 << shift), 30000); - m_reconnectTimer.start(delay); - return; - } + // Identify the console; doubles as an initial liveness probe. Connection + // itself is established the moment the socket connects (per SCP practice). + sendCommand(QByteArray("devinfo productname\n")); - if (!m_receiveSocket.bind(QHostAddress::Any, m_port)) { - lo_address_free(m_oscAddress); - m_oscAddress = nullptr; - int shift = std::min(m_reconnectAttempts - 1, 10); - int delay = std::min(m_reconnectDelayMs * (1 << shift), 30000); - m_reconnectTimer.start(delay); - return; - } + emit connected(); +} - m_waitingForModel = true; - sendOscMessage("/sys/model"); - m_connectionTimer.start(m_connectionTimeoutMs); +void YamahaProtocol::onTransportDisconnected() { + m_keepAliveTimer.stop(); + m_requestTimeoutTimer.stop(); + setConnectionState(ConnectionState::Disconnected); + setStatus("Disconnected"); + emit disconnected(); } -void YamahaProtocol::sendOscMessage(const QString& path) { - if (!m_oscAddress) { - return; - } - lo_send(m_oscAddress, path.toUtf8().constData(), nullptr); +void YamahaProtocol::onTransportError(const QString& error) { + setStatus("Connection error: " + error); + emit connectionError(error); } -void YamahaProtocol::sendOscMessage(const QString& path, float value) { - if (!m_oscAddress) { - return; - } - lo_send(m_oscAddress, path.toUtf8().constData(), "f", value); +void YamahaProtocol::onTransportConnectionLost() { + m_keepAliveTimer.stop(); + setConnectionState(ConnectionState::Reconnecting); + setStatus("Connection lost - reconnecting..."); + emit connectionLost(); } -void YamahaProtocol::sendOscMessage(const QString& path, int value) { - if (!m_oscAddress) { - return; - } - lo_send(m_oscAddress, path.toUtf8().constData(), "i", value); +void YamahaProtocol::onReconnecting(int attempt, int maxAttempts) { + setConnectionState(ConnectionState::Reconnecting); + setStatus(QString("Reconnecting (attempt %1/%2)...").arg(attempt).arg(maxAttempts)); } -void YamahaProtocol::sendOscMessage(const QString& path, const QString& value) { - if (!m_oscAddress) { - return; +// -------------------------------------------------------------------------- +// Incoming data. +// -------------------------------------------------------------------------- + +void YamahaProtocol::onDataReceived(const QByteArray& data) { + m_rxBuffer.append(data); + + int nl; + while ((nl = m_rxBuffer.indexOf('\n')) >= 0) { + QByteArray line = m_rxBuffer.left(nl); + m_rxBuffer.remove(0, nl + 1); + if (line.endsWith('\r')) + line.chop(1); + parseScpLine(line); } - lo_send(m_oscAddress, path.toUtf8().constData(), "s", value.toUtf8().constData()); + + if (m_rxBuffer.size() > MAX_RX_BUFFER) + m_rxBuffer.clear(); // guard against a peer that never sends LF } -void YamahaProtocol::parseOscMessage(const QByteArray& data) { - if (data.size() < 4) { +void YamahaProtocol::parseScpLine(const QByteArray& rawLine) { + const QString line = QString::fromUtf8(rawLine).trimmed(); + if (line.isEmpty()) return; + + m_lastResponseTime = QDateTime::currentMSecsSinceEpoch(); + + // Tokenize on whitespace, but keep "quoted strings" as single tokens. + QStringList tokens; + { + bool inQuotes = false; + QString cur; + for (const QChar c : line) { + if (c == '"') { + inQuotes = !inQuotes; + continue; + } + if (c.isSpace() && !inQuotes) { + if (!cur.isEmpty()) { + tokens.append(cur); + cur.clear(); + } + } else { + cur.append(c); + } + } + if (!cur.isEmpty()) + tokens.append(cur); } + if (tokens.isEmpty()) + return; - // extract path - int pathEnd = data.indexOf('\0'); - if (pathEnd < 0) { + const QString& status = tokens.first(); + // Per-command "ERROR" lines are routine (e.g. querying an unsupported node) + // and are ignored here rather than surfaced as connection errors. + if (status != "OK" && status != "OKm" && status != "NOTIFY" && status != "NOTIFYm") return; - } - QString path = QString::fromUtf8(data.left(pathEnd)); - // skip to type tag (4-byte aligned) - int typeStart = ((pathEnd + 4) / 4) * 4; - if (typeStart >= data.size()) { - processResponse(path, QVariant()); + // Device identification: e.g. OK devinfo productname "CL5" + if (tokens.size() >= 4 && tokens.at(1) == "devinfo" && tokens.at(2) == "productname") { + setStatus(QString("Connected to %1 (%2:%3)").arg(tokens.at(3)).arg(m_host).arg(m_port)); return; } - // find type tag - if (data.at(typeStart) != ',') { - return; + // Find the parameter address (the "MIXER:..." token). + int ai = -1; + for (int i = 1; i < tokens.size(); ++i) { + if (tokens.at(i).startsWith("MIXER:")) { + ai = i; + break; + } } - int typeEnd = data.indexOf('\0', typeStart); - if (typeEnd < 0) { + if (ai < 0) return; - } - QString types = QString::fromUtf8(data.mid(typeStart + 1, typeEnd - typeStart - 1)); - // skip to arguments (4-byte aligned) - int argOffset = ((typeEnd + 4) / 4) * 4; - if (argOffset > data.size()) { - return; + const QString address = tokens.at(ai); + + // Value: for "set"/"get" it follows address + idx1 + idx2; for scene-style + // lines (ssrecall_ex MIXER:Lib/Scene ) it follows the address directly. + QVariant value; + auto parseToken = [](const QString& t) -> QVariant { + bool ok = false; + int iv = t.toInt(&ok); + return ok ? QVariant(iv) : QVariant(t); + }; + if (tokens.size() > ai + 3) + value = parseToken(tokens.at(ai + 3)); + else if (tokens.size() > ai + 1) + value = parseToken(tokens.at(ai + 1)); + + if (m_pendingRequests.contains(address)) { + const YamahaPendingRequest req = m_pendingRequests.take(address); + updateLatency(m_lastResponseTime - req.sentTime); + if (req.callback) + req.callback(address, value, true); } - if (!types.isEmpty()) { - QVariant value = parseOscArgument(data, argOffset, types.at(0).toLatin1()); - processResponse(path, value); + if (value.isValid()) { + m_parameterCache[address] = value; + emit parameterChanged(address, value); } } -QVariant YamahaProtocol::parseOscArgument(const QByteArray& data, int& offset, char type) { - switch (type) { - case 'f': { - if (offset + 4 <= data.size()) { - quint32 raw = (static_cast(data[offset]) << 24) | - (static_cast(data[offset + 1]) << 16) | - (static_cast(data[offset + 2]) << 8) | - static_cast(data[offset + 3]); - float f; - std::memcpy(&f, &raw, sizeof(float)); - offset += 4; - return f; - } +// -------------------------------------------------------------------------- +// Parameter API. +// -------------------------------------------------------------------------- + +void YamahaProtocol::sendParameter(const QString& path, const QVariant& value) { + if (m_connectionState != ConnectionState::Connected) + return; + + // `path` is treated as a raw SCP target ("
", or just + // an address for which 0 0 is assumed). + const QString target = path.contains(' ') ? path : (path + " 0 0"); + + QString valueStr; + switch (value.typeId()) { + case QMetaType::Bool: + valueStr = value.toBool() ? "1" : "0"; break; - } - case 'i': { - if (offset + 4 <= data.size()) { - qint32 i = (static_cast(data[offset]) << 24) | - (static_cast(data[offset + 1]) << 16) | - (static_cast(data[offset + 2]) << 8) | - static_cast(data[offset + 3]); - offset += 4; - return i; - } + case QMetaType::Int: + case QMetaType::LongLong: + valueStr = QString::number(value.toLongLong()); break; - } - case 's': { - int strEnd = data.indexOf('\0', offset); - if (strEnd > offset) { - QString str = QString::fromUtf8(data.mid(offset, strEnd - offset)); - offset = ((strEnd + 4) / 4) * 4; - return str; - } + case QMetaType::Double: + case QMetaType::Float: + valueStr = QString::number(std::lround(value.toDouble())); break; - } - case 'b': { - if (offset + 4 <= data.size()) { - qint32 size = (static_cast(data[offset]) << 24) | - (static_cast(data[offset + 1]) << 16) | - (static_cast(data[offset + 2]) << 8) | - static_cast(data[offset + 3]); - if (size < 0) - break; - offset += 4; - if (offset + size <= data.size()) { - QByteArray blob = data.mid(offset, size); - offset += ((size + 3) / 4) * 4; - return blob; - } - } + case QMetaType::QString: + valueStr = "\"" + value.toString() + "\""; + break; + default: + valueStr = QString::number(std::lround(value.toDouble())); break; - } } - return QVariant(); + sendCommand(("set " + target + " " + valueStr + "\n").toUtf8()); + m_parameterCache[path] = value; } -void YamahaProtocol::processResponse(const QString& path, const QVariant& value) { - qint64 now = QDateTime::currentMSecsSinceEpoch(); - m_lastResponseTime = now; +QVariant YamahaProtocol::getParameter(const QString& path) { return m_parameterCache.value(path); } - if (path == "/sys/model") { - handleModelResponse(value); +void YamahaProtocol::requestParameter(const QString& path) { + if (m_connectionState != ConnectionState::Connected) return; - } - if (m_pendingRequests.contains(path)) { - YamahaPendingRequest req = m_pendingRequests.take(path); - qint64 roundTrip = now - req.sentTime; - updateLatency(roundTrip); + const QString target = path.contains(' ') ? path : (path + " 0 0"); + const QString address = path.section(' ', 0, 0); - if (req.callback) { - req.callback(path, value, true); - } - } + YamahaPendingRequest req; + req.address = address; + req.sentTime = QDateTime::currentMSecsSinceEpoch(); + req.callback = nullptr; + m_pendingRequests[address] = req; - if (value.isValid()) { - m_parameterCache[path] = value; - emit parameterChanged(path, value); + sendCommand(("get " + target + "\n").toUtf8()); +} + +void YamahaProtocol::requestParameterAsync(const QString& path, ParameterCallback callback) { + if (m_connectionState != ConnectionState::Connected) { + if (callback) + callback(path, QVariant(), false); + return; } + + const QString target = path.contains(' ') ? path : (path + " 0 0"); + const QString address = path.section(' ', 0, 0); + + YamahaPendingRequest req; + req.address = address; + req.sentTime = QDateTime::currentMSecsSinceEpoch(); + req.callback = std::move(callback); + m_pendingRequests[address] = req; + + sendCommand(("get " + target + "\n").toUtf8()); } -void YamahaProtocol::handleModelResponse([[maybe_unused]] const QVariant& value) { - m_connectionTimer.stop(); - m_waitingForModel = false; +void YamahaProtocol::recallSnapshot(const Cue& cue) { + if (m_connectionState != ConnectionState::Connected) + return; - if (m_connectionState == ConnectionState::Connecting || - m_connectionState == ConnectionState::Reconnecting) { - setConnectionState(ConnectionState::Connected); - setStatus(QString("Connected to %1:%2").arg(m_host).arg(m_transmitPort)); + const QJsonObject params = cue.parameters(); + for (const auto& [path, value] : params.asKeyValueRange()) + sendParameter(path.toString(), value.toVariant()); +} - m_keepAliveTimer.start(KEEPALIVE_INTERVAL); - m_requestTimeoutTimer.start(); - m_reconnectAttempts = 0; +void YamahaProtocol::recallScene(int sceneNumber) { + if (m_connectionState != ConnectionState::Connected) + return; + + sendCommand(scpSceneRecall(sceneNumber)); + emit sceneChanged(sceneNumber); +} + +void YamahaProtocol::refresh() { + if (m_connectionState != ConnectionState::Connected) + return; - emit connected(); + // Re-query input-channel fader level / on so cached state matches console. + const int n = std::max(0, m_capabilities.inputChannels); + for (int ch = 0; ch < n; ++ch) { + sendCommand(scpGet(AddrFaderLevel, ch, 0)); + sendCommand(scpGet(AddrFaderOn, ch, 0)); } } -void YamahaProtocol::startReconnection() { - m_keepAliveTimer.stop(); - m_connectionTimer.stop(); - m_requestTimeoutTimer.stop(); +// -------------------------------------------------------------------------- +// Timers / housekeeping. +// -------------------------------------------------------------------------- - setConnectionState(ConnectionState::Reconnecting); - m_reconnectTimer.start(m_reconnectDelayMs); +void YamahaProtocol::onKeepAliveTimeout() { + if (m_connectionState == ConnectionState::Connected) + sendCommand(QByteArray("devinfo productname\n")); } -void YamahaProtocol::updateLatency(qint64 roundTripMs) { - m_latencyHistory.append(static_cast(roundTripMs)); +void YamahaProtocol::onRequestTimeoutCheck() { + if (m_pendingRequests.isEmpty()) + return; - while (m_latencyHistory.size() > LATENCY_HISTORY_SIZE) { - m_latencyHistory.removeFirst(); + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + QStringList timedOut; + for (const auto& [address, req] : m_pendingRequests.asKeyValueRange()) { + if (now - req.sentTime > REQUEST_TIMEOUT_MS) + timedOut.append(address); } - int sum = 0; - for (int lat : m_latencyHistory) { - sum += lat; + for (const QString& address : timedOut) { + const YamahaPendingRequest req = m_pendingRequests.take(address); + if (req.callback) + req.callback(address, QVariant(), false); + emit requestTimeout(address); } - int newLatency = sum / m_latencyHistory.size(); +} - if (newLatency != m_latencyMs) { - m_latencyMs = newLatency; +void YamahaProtocol::sendCommand(const QByteArray& cmd) { + if (cmd.isEmpty() || !m_transport.isConnected()) + return; + m_transport.send(cmd); +} + +void YamahaProtocol::updateLatency(qint64 roundTripMs) { + if (roundTripMs < 0) + return; + // Simple exponential moving average. + const int sample = static_cast(roundTripMs); + const int next = m_latencyMs == 0 ? sample : (m_latencyMs * 3 + sample) / 4; + if (next != m_latencyMs) { + m_latencyMs = next; emit latencyChanged(m_latencyMs); } } diff --git a/src/protocol/yamaha/YamahaProtocol.h b/src/protocol/yamaha/YamahaProtocol.h index 6ab9139..2beef9c 100644 --- a/src/protocol/yamaha/YamahaProtocol.h +++ b/src/protocol/yamaha/YamahaProtocol.h @@ -2,18 +2,32 @@ #include "../MixerCapabilities.h" #include "../MixerProtocol.h" -#include "../transport/OscTransport.h" -#include +#include "../transport/TcpTransport.h" +#include #include -#include +#include #include -#include namespace OpenMix { +// Yamaha SCP (Remote Control Protocol) driver. +// +// ASCII line protocol over TCP port 49280, used by CL / QL / Rivage / DM7 +// consoles (the same transport TheatreMix drives). Commands are LF-terminated +// text lines: +// +// set
\n +// get
\n +// ssrecall_ex MIXER:Lib/Scene \n +// +// idx1 is the 0-based channel index (console channel 1 -> 0). idx2 selects a +// sub-element (0 for channel scalars, the EQ band index for EQ bands). Numeric +// values are scaled integers, e.g. fader level is centi-dB: 0 dB = 0, +// +10 dB = 1000, -inf = -32768. Responses and console-side changes arrive as +// lines beginning "OK"/"OKm"/"NOTIFY" and are parsed into parameterChanged(). + struct YamahaPendingRequest { - QString path; - QDateTime timestamp; + QString address; qint64 sentTime = 0; ParameterCallback callback; }; @@ -22,15 +36,41 @@ class YamahaProtocol : public MixerProtocol { Q_OBJECT public: + // SCP listens on TCP 49280 on every CL/QL/Rivage/DM7 console. + static constexpr int SCP_PORT = 49280; + + // Fader level constants in centi-dB (MIXER:Current/InCh/Fader/Level). + static constexpr int FADER_MAX_CENTIDB = 1000; // +10 dB (top of throw) + static constexpr int FADER_MIN_CENTIDB = -13800; // -138 dB (bottom, non-inf) + static constexpr int FADER_NEG_INF = -32768; // -inf + + // SCP node addresses for input channels. Public so callers/tests can reuse. + static constexpr auto AddrFaderLevel = "MIXER:Current/InCh/Fader/Level"; + static constexpr auto AddrFaderOn = "MIXER:Current/InCh/Fader/On"; + static constexpr auto AddrHaGain = "MIXER:Current/InCh/Port/HA/Gain"; + static constexpr auto AddrHpfOn = "MIXER:Current/InCh/HPF/On"; + static constexpr auto AddrHpfFreq = "MIXER:Current/InCh/HPF/Freq"; + static constexpr auto AddrEqOn = "MIXER:Current/InCh/PEQ/On"; + static constexpr auto AddrEqBandBypass = "MIXER:Current/InCh/PEQ/Band/Bypass"; + static constexpr auto AddrEqBandFreq = "MIXER:Current/InCh/PEQ/Band/Freq"; + static constexpr auto AddrEqBandGain = "MIXER:Current/InCh/PEQ/Band/Gain"; + static constexpr auto AddrEqBandQ = "MIXER:Current/InCh/PEQ/Band/Q"; + static constexpr auto AddrDynaThreshold = "MIXER:Current/InCh/Dyna2/Threshold"; + static constexpr auto SceneLib = "MIXER:Lib/Scene"; + explicit YamahaProtocol(const MixerCapabilities& caps, QObject* parent = nullptr); ~YamahaProtocol() override; - [[nodiscard]] QString protocolName() const override { return "Yamaha OSC"; } - [[nodiscard]] QString protocolDescription() const override { return "Yamaha OSC/UDP Protocol"; } + [[nodiscard]] QString protocolName() const override { return "Yamaha SCP"; } + [[nodiscard]] QString protocolDescription() const override { + return "Yamaha SCP (Remote Control Protocol) over TCP 49280"; + } [[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; } @@ -45,81 +85,92 @@ class YamahaProtocol : public MixerProtocol { [[nodiscard]] int latencyMs() const override { return m_latencyMs; } [[nodiscard]] const MixerCapabilities& capabilities() const override { return m_capabilities; } + [[nodiscard]] bool supportsParameterFeedback() const override { return true; } + + // --- 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 setChannelMute(int ch, bool muted) override; + void setChannelPreamp(int ch, double gainDb) override; + void setChannelHpf(int ch, bool on, double freqHz) override; + void setChannelEqOn(int ch, bool on) override; + void setChannelEqBand(int ch, int band, bool on, int type, double freqHz, double gainDb, + double q) override; + void setChannelDynamics(int ch, bool on, double thresholdDb, double ratio, double attackMs, + double releaseMs, double makeupDb) override; + + // --- pure command builders: produce the exact SCP ASCII bytes (LF-terminated). --- + [[nodiscard]] static QByteArray scpSet(const QString& address, int idx1, int idx2, int value); + [[nodiscard]] static QByteArray scpSet(const QString& address, int idx1, int idx2, + const QString& value); + [[nodiscard]] static QByteArray scpGet(const QString& address, int idx1, int idx2); + [[nodiscard]] static QByteArray scpSceneRecall(int sceneNumber); + + // --- pure value scaling helpers. --- + [[nodiscard]] static int faderLevelToScp(double level0to1); // 0..1 -> centi-dB taper + [[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) + + // --- semantic command builders: the exact bytes each setter sends. --- + [[nodiscard]] QByteArray buildChannelFader(int ch, double level) const; + [[nodiscard]] QByteArray buildChannelMute(int ch, bool muted) const; + [[nodiscard]] QByteArray buildChannelPreamp(int ch, double gainDb) const; + [[nodiscard]] QByteArray buildChannelHpfOn(int ch, bool on) const; + [[nodiscard]] QByteArray buildChannelHpfFreq(int ch, double freqHz) const; + [[nodiscard]] QByteArray buildChannelEqOn(int ch, bool on) const; + [[nodiscard]] QByteArray buildChannelEqBandBypass(int ch, int band, bool on) const; + [[nodiscard]] QByteArray buildChannelEqBandFreq(int ch, int band, double freqHz) const; + [[nodiscard]] QByteArray buildChannelEqBandGain(int ch, int band, double gainDb) const; + [[nodiscard]] QByteArray buildChannelEqBandQ(int ch, int band, double q) const; + [[nodiscard]] QByteArray buildChannelDynamicsThreshold(int ch, double thresholdDb) const; protected: - virtual void initializeSnapshotParams(); - void rebuildSnapshotParams(); - - // appends per-channel fader/on, EQ, and effect-send snapshot parameters for - // `channelCount` channels. `channelPrefix` is the path stem (e.g. "/ch/"), - // `channelFieldWidth` controls zero-padding (2 for CL/QL/TF, 3 for DM7), and - // `maxEffectSends` is the model-specific send-bus ceiling applied on top of - // m_capabilities.effectSendBuses. - void appendEqSnapshotParams(QStringList& params, const QString& channelPrefix, - int channelCount, int channelFieldWidth = 2, - int maxEffectSends = 16) const; + // Head-amp (preamp) gain scaling. CL/QL/Rivage transmit centi-dB; the DM7 + // subclass overrides this to send whole-dB values. + [[nodiscard]] virtual int preampGainToScp(double gainDb) const; - MixerCapabilities m_capabilities; - QStringList m_snapshotParams; + // Parse one already-split SCP response/notify line (no trailing newline). + void parseScpLine(const QByteArray& line); - // Yamaha uses separate receive/transmit ports - int m_receivePort = 8000; - int m_transmitPort = 9000; + MixerCapabilities m_capabilities; + TcpTransport m_transport; private slots: - void onReadyRead(); + void onTransportConnected(); + void onTransportDisconnected(); + void onTransportError(const QString& error); + void onTransportConnectionLost(); + void onDataReceived(const QByteArray& data); void onKeepAliveTimeout(); - void onConnectionTimeout(); + void onReconnecting(int attempt, int maxAttempts); void onRequestTimeoutCheck(); - void onReconnectAttempt(); - - protected: - void parseOscMessage(const QByteArray& data); private: - void sendOscMessage(const QString& path); - void sendOscMessage(const QString& path, float value); - void sendOscMessage(const QString& path, int value); - void sendOscMessage(const QString& path, const QString& value); - QVariant parseOscArgument(const QByteArray& data, int& offset, char type); - - void processResponse(const QString& path, const QVariant& value); - void handleModelResponse(const QVariant& value); - void startReconnection(); - void updateLatency(qint64 roundTripMs); + void sendCommand(const QByteArray& cmd); void setStatus(const QString& status); void setConnectionState(ConnectionState state); + void updateLatency(qint64 roundTripMs); - QUdpSocket m_receiveSocket; - lo_address m_oscAddress = nullptr; QString m_host; - int m_port = 0; + int m_port = SCP_PORT; - QTimer m_keepAliveTimer; - QTimer m_connectionTimer; - QTimer m_requestTimeoutTimer; - QTimer m_reconnectTimer; + ConnectionState m_connectionState = ConnectionState::Disconnected; + QString m_statusMessage; + QByteArray m_rxBuffer; QHash m_parameterCache; QHash m_pendingRequests; - ConnectionState m_connectionState = ConnectionState::Disconnected; - QString m_statusMessage; + QTimer m_keepAliveTimer; + QTimer m_requestTimeoutTimer; qint64 m_lastResponseTime = 0; int m_latencyMs = 0; - QList m_latencyHistory; - - int m_reconnectAttempts = 0; - bool m_waitingForModel = false; - - static constexpr int KEEPALIVE_INTERVAL = 8000; - static constexpr int LATENCY_HISTORY_SIZE = 10; - int m_connectionTimeoutMs = 5000; - int m_requestTimeoutMs = 2000; - int m_maxReconnectAttempts = 5; - int m_reconnectDelayMs = 1000; + static constexpr int KEEPALIVE_INTERVAL = 5000; + static constexpr int REQUEST_TIMEOUT_MS = 2000; + static constexpr int MAX_RX_BUFFER = 65536; }; } // namespace OpenMix diff --git a/src/protocol/yamaha/YamahaQLProtocol.cpp b/src/protocol/yamaha/YamahaQLProtocol.cpp index e98876c..d6aabba 100644 --- a/src/protocol/yamaha/YamahaQLProtocol.cpp +++ b/src/protocol/yamaha/YamahaQLProtocol.cpp @@ -3,21 +3,6 @@ namespace OpenMix { YamahaQLProtocol::YamahaQLProtocol(const MixerCapabilities& caps, QObject* parent) - : YamahaProtocol(caps, parent) { - initializeSnapshotParams(); -} - -void YamahaQLProtocol::initializeSnapshotParams() { - m_snapshotParams.clear(); - - // QL series input channels (up to 64), 16 effect-send buses max - appendEqSnapshotParams(m_snapshotParams, "/ch/", 64, 2, 16); - - // QL series has 8 DCAs - for (int i = 1; i <= m_capabilities.dcaCount && i <= 8; ++i) { - m_snapshotParams.append(QString("/dca/%1/fader").arg(i)); - m_snapshotParams.append(QString("/dca/%1/on").arg(i)); - } -} + : YamahaProtocol(caps, parent) {} } // namespace OpenMix diff --git a/src/protocol/yamaha/YamahaQLProtocol.h b/src/protocol/yamaha/YamahaQLProtocol.h index d316c2d..bc9a9b9 100644 --- a/src/protocol/yamaha/YamahaQLProtocol.h +++ b/src/protocol/yamaha/YamahaQLProtocol.h @@ -4,18 +4,15 @@ namespace OpenMix { -// Yamaha QL series (QL1, QL5) protocol -// 8 DCAs, 32-64 input channels (model dependent), 16 mix buses, 8 matrix +// Yamaha QL series (QL1, QL5). SCP over TCP 49280. +// 8 DCAs, 32-64 input channels (model dependent), 16 mix buses, 8 matrix. class YamahaQLProtocol : public YamahaProtocol { Q_OBJECT public: explicit YamahaQLProtocol(const MixerCapabilities& caps, QObject* parent = nullptr); - [[nodiscard]] QString protocolDescription() const override { return "Yamaha QL Protocol"; } - - protected: - void initializeSnapshotParams() override; + [[nodiscard]] QString protocolDescription() const override { return "Yamaha QL SCP Protocol"; } }; } // namespace OpenMix diff --git a/src/protocol/yamaha/YamahaTFProtocol.cpp b/src/protocol/yamaha/YamahaTFProtocol.cpp index 480c34a..2dcfec9 100644 --- a/src/protocol/yamaha/YamahaTFProtocol.cpp +++ b/src/protocol/yamaha/YamahaTFProtocol.cpp @@ -3,21 +3,6 @@ namespace OpenMix { YamahaTFProtocol::YamahaTFProtocol(const MixerCapabilities& caps, QObject* parent) - : YamahaProtocol(caps, parent) { - initializeSnapshotParams(); -} - -void YamahaTFProtocol::initializeSnapshotParams() { - m_snapshotParams.clear(); - - // TF series input channels (up to 40), 8 effect-send buses max - appendEqSnapshotParams(m_snapshotParams, "/ch/", 40, 2, 8); - - // TF series has 8 DCAs - for (int i = 1; i <= m_capabilities.dcaCount && i <= 8; ++i) { - m_snapshotParams.append(QString("/dca/%1/fader").arg(i)); - m_snapshotParams.append(QString("/dca/%1/on").arg(i)); - } -} + : YamahaProtocol(caps, parent) {} } // namespace OpenMix diff --git a/src/protocol/yamaha/YamahaTFProtocol.h b/src/protocol/yamaha/YamahaTFProtocol.h index 6a1981a..8057f1f 100644 --- a/src/protocol/yamaha/YamahaTFProtocol.h +++ b/src/protocol/yamaha/YamahaTFProtocol.h @@ -4,18 +4,17 @@ namespace OpenMix { -// Yamaha TF series (TF1, TF3, TF5) protocol -// 8 DCAs, 16-40 input channels (model dependent), 20 mix buses +// Yamaha TF series (TF1, TF3, TF5). +// NOTE: the TF series uses a more limited control protocol than the CL/QL/ +// Rivage/DM7 SCP it shares a base with here. This driver is a best-effort +// SCP stub for TF; correctness is targeted at CL/QL/DM7. class YamahaTFProtocol : public YamahaProtocol { Q_OBJECT public: explicit YamahaTFProtocol(const MixerCapabilities& caps, QObject* parent = nullptr); - [[nodiscard]] QString protocolDescription() const override { return "Yamaha TF Protocol"; } - - protected: - void initializeSnapshotParams() override; + [[nodiscard]] QString protocolDescription() const override { return "Yamaha TF SCP Protocol"; } }; } // namespace OpenMix diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 91f1048..6ce4a05 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -33,6 +33,9 @@ add_executable(test_show ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp + ${CMAKE_SOURCE_DIR}/src/core/ActorProfile.cpp + ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp + ${CMAKE_SOURCE_DIR}/src/core/ActorProfileLibrary.cpp ) target_link_libraries(test_show PRIVATE Qt6::Core @@ -43,6 +46,98 @@ target_include_directories(test_show PRIVATE ) add_test(NAME ShowTest COMMAND test_show) +add_executable(test_actor_profiles + test_actor_profiles.cpp + ${CMAKE_SOURCE_DIR}/src/core/ActorProfile.cpp + ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp + ${CMAKE_SOURCE_DIR}/src/core/ActorProfileLibrary.cpp + ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp + ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp + ${CMAKE_SOURCE_DIR}/src/core/Show.cpp +) +target_link_libraries(test_actor_profiles PRIVATE + Qt6::Core + Qt6::Test +) +target_include_directories(test_actor_profiles PRIVATE + ${CMAKE_SOURCE_DIR}/src +) +add_test(NAME ActorProfilesTest COMMAND test_actor_profiles) + +add_executable(test_playback_profiles + test_playback_profiles.cpp + ${CMAKE_SOURCE_DIR}/src/core/PlaybackEngine.cpp + ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp + ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueValidator.cpp + ${CMAKE_SOURCE_DIR}/src/core/PlaybackGuard.cpp + ${CMAKE_SOURCE_DIR}/src/core/PlaybackLogger.cpp + ${CMAKE_SOURCE_DIR}/src/core/ActorProfile.cpp + ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp + ${CMAKE_SOURCE_DIR}/src/core/ActorProfileLibrary.cpp + ${CMAKE_SOURCE_DIR}/src/core/FadeEngine.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/LoopbackProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/MixerProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/MixerCapabilities.cpp +) +target_link_libraries(test_playback_profiles PRIVATE + Qt6::Core + Qt6::Test +) +target_include_directories(test_playback_profiles PRIVATE + ${CMAKE_SOURCE_DIR}/src +) +add_test(NAME PlaybackProfilesTest COMMAND test_playback_profiles) + +add_executable(test_fade_engine + test_fade_engine.cpp + ${CMAKE_SOURCE_DIR}/src/core/FadeEngine.cpp +) +target_link_libraries(test_fade_engine PRIVATE + Qt6::Core + Qt6::Test +) +target_include_directories(test_fade_engine PRIVATE + ${CMAKE_SOURCE_DIR}/src +) +add_test(NAME FadeEngineTest COMMAND test_fade_engine) + +add_executable(test_msc test_msc.cpp) +target_link_libraries(test_msc PRIVATE Qt6::Core Qt6::Test) +target_include_directories(test_msc PRIVATE ${CMAKE_SOURCE_DIR}/src) +add_test(NAME MscTest COMMAND test_msc) + +add_executable(test_osc_remote + test_osc_remote.cpp + ${CMAKE_SOURCE_DIR}/src/app/OscRemoteServer.cpp + ${CMAKE_SOURCE_DIR}/src/core/PlaybackEngine.cpp + ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp + ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueValidator.cpp + ${CMAKE_SOURCE_DIR}/src/core/PlaybackGuard.cpp + ${CMAKE_SOURCE_DIR}/src/core/PlaybackLogger.cpp + ${CMAKE_SOURCE_DIR}/src/core/ActorProfile.cpp + ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp + ${CMAKE_SOURCE_DIR}/src/core/ActorProfileLibrary.cpp + ${CMAKE_SOURCE_DIR}/src/core/FadeEngine.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/MixerProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/MixerCapabilities.cpp +) +target_link_libraries(test_osc_remote PRIVATE Qt6::Core Qt6::Test PkgConfig::LIBLO) +target_include_directories(test_osc_remote PRIVATE ${CMAKE_SOURCE_DIR}/src) +add_test(NAME OscRemoteTest COMMAND test_osc_remote) + +add_executable(test_qlab_client + test_qlab_client.cpp + ${CMAKE_SOURCE_DIR}/src/app/QLabClient.cpp +) +target_link_libraries(test_qlab_client PRIVATE Qt6::Core Qt6::Test PkgConfig::LIBLO) +target_include_directories(test_qlab_client PRIVATE ${CMAKE_SOURCE_DIR}/src) +add_test(NAME QLabClientTest COMMAND test_qlab_client) + add_executable(test_undo_commands test_undo_commands.cpp ${CMAKE_SOURCE_DIR}/src/core/UndoCommands.cpp @@ -83,30 +178,30 @@ if(WIN32) endif() add_test(NAME AllenHeathParsingTest COMMAND test_allenheath_parsing) -add_executable(test_yamaha_osc - test_yamaha_osc.cpp +add_executable(test_yamaha_scp + test_yamaha_scp.cpp ${CMAKE_SOURCE_DIR}/src/protocol/yamaha/YamahaProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/yamaha/YamahaCLProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/yamaha/YamahaQLProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/yamaha/YamahaTFProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/yamaha/YamahaDM7Protocol.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_yamaha_osc PRIVATE +# Yamaha now speaks SCP over TCP (TcpTransport) and no longer needs liblo. +target_link_libraries(test_yamaha_scp PRIVATE Qt6::Core Qt6::Network Qt6::Test ) -if(WIN32) - target_include_directories(test_yamaha_osc PRIVATE ${LIBLO_INCLUDE_DIRS}) - target_link_libraries(test_yamaha_osc PRIVATE ${LIBLO_LIBRARIES}) -else() - target_link_libraries(test_yamaha_osc PRIVATE PkgConfig::LIBLO) -endif() -target_include_directories(test_yamaha_osc PRIVATE +target_include_directories(test_yamaha_scp PRIVATE ${CMAKE_SOURCE_DIR}/src ) if(WIN32) - target_compile_definitions(test_yamaha_osc PRIVATE NOMINMAX) + target_compile_definitions(test_yamaha_scp PRIVATE NOMINMAX) endif() -add_test(NAME YamahaOscTest COMMAND test_yamaha_osc) +add_test(NAME YamahaScpTest COMMAND test_yamaha_scp) diff --git a/tests/test_actor_profiles.cpp b/tests/test_actor_profiles.cpp new file mode 100644 index 0000000..7ed792a --- /dev/null +++ b/tests/test_actor_profiles.cpp @@ -0,0 +1,203 @@ +#include "core/Actor.h" +#include "core/ActorProfile.h" +#include "core/ActorProfileLibrary.h" +#include "core/Cue.h" +#include "core/Show.h" +#include +#include + +using namespace OpenMix; + +class TestActorProfiles : public QObject { + Q_OBJECT + + private slots: + // --- VoiceData --------------------------------------------------------- + void voiceData_roundTrips_onlySetFields() { + VoiceData v; + v.gainDb = 12.5; + v.hpfOn = true; + v.hpfFreq = 80.0; + v.eqOn = true; + v.eqBands.append(EqBand{2, true, 1, 2500.0, -3.0, 1.4}); + v.dynOn = true; + v.dynThreshold = -18.0; + v.dynRatio = 3.0; + + const QJsonObject json = v.toJson(); + QVERIFY(!json.contains("dynRelease")); // unset stays absent + + VoiceData r = VoiceData::fromJson(json); + QVERIFY(r.gainDb.has_value()); + QCOMPARE(*r.gainDb, 12.5); + QCOMPARE(*r.hpfFreq, 80.0); + QCOMPARE(r.eqBands.size(), 1); + QCOMPARE(r.eqBands[0].freq, 2500.0); + QCOMPARE(*r.dynRatio, 3.0); + QVERIFY(!r.dynRelease.has_value()); + } + + void voiceData_empty_isEmpty() { + VoiceData v; + QVERIFY(v.isEmpty()); + v.gainDb = 0.0; // 0 is still "set" + QVERIFY(!v.isEmpty()); + } + + // --- ActorProfile (main + backup) ------------------------------------- + void actorProfile_mainAndBackup_roundTrip() { + ActorProfile p; + p.main().gainDb = 6.0; + p.backup().gainDb = -90.0; + + ActorProfile r = ActorProfile::fromJson(p.toJson()); + QCOMPARE(*r.main().gainDb, 6.0); + QCOMPARE(*r.backup().gainDb, -90.0); + } + + // --- Actor ------------------------------------------------------------- + void actor_storesProfilesPerSlot_roundTrip() { + Actor a("Alice", 5); + ActorProfile main; + main.main().gainDb = 3.0; + a.setProfile("Main", main); + + QVERIFY(a.hasProfile("Main")); + QCOMPARE(a.profileSlots().size(), 1); + + Actor r = Actor::fromJson(a.toJson()); + QCOMPARE(r.name(), QString("Alice")); + QCOMPARE(r.channel(), 5); + QCOMPARE(r.id(), a.id()); + QVERIFY(r.hasProfile("Main")); + QCOMPARE(*r.profile("Main").main().gainDb, 3.0); + } + + // --- ActorProfileLibrary: lookup + backup ----------------------------- + void library_voiceFor_resolvesActiveActor() { + ActorProfileLibrary lib; + Actor a("Bob", 7); + ActorProfile p; + p.main().gainDb = 4.0; + p.backup().gainDb = -80.0; + a.setProfile("Main", p); + lib.addActor(a); + + auto main = lib.voiceFor(7, "Main"); + QVERIFY(main.has_value()); + QCOMPARE(*main->gainDb, 4.0); + + // flip channel to backup mic -> backup voice resolves + lib.setBackup(7, true); + auto backup = lib.voiceFor(7, "Main"); + QVERIFY(backup.has_value()); + QCOMPARE(*backup->gainDb, -80.0); + + // unknown channel / slot -> nullopt + QVERIFY(!lib.voiceFor(8, "Main").has_value()); + QVERIFY(!lib.voiceFor(7, "Solo").has_value()); + } + + void library_actorForChannel_prefersLowestOrderActive() { + ActorProfileLibrary lib; + Actor primary("Lead", 3); + primary.setOrder(1); + Actor understudy("Understudy", 3); + understudy.setOrder(2); + lib.addActor(understudy); + lib.addActor(primary); + + QCOMPARE(lib.actorForChannel(3)->name(), QString("Lead")); + + // deactivate lead -> understudy takes the channel + primary.setActive(false); + lib.updateActor(primary.id(), primary); + QCOMPARE(lib.actorForChannel(3)->name(), QString("Understudy")); + } + + void library_emitsSignals() { + ActorProfileLibrary lib; + QSignalSpy added(&lib, &ActorProfileLibrary::actorAdded); + QSignalSpy changed(&lib, &ActorProfileLibrary::changed); + + Actor a("Cleo", 1); + lib.addActor(a); + + QCOMPARE(added.count(), 1); + QVERIFY(changed.count() >= 1); + } + + void library_roundTrips() { + ActorProfileLibrary lib; + lib.setProfileSlots({"Main", "Solo"}); + Actor a("Dana", 2); + ActorProfile p; + p.main().eqOn = true; + p.main().eqBands.append(EqBand{1, true, 0, 100.0, 2.0, 1.0}); + a.setProfile("Solo", p); + lib.addActor(a); + lib.setBackup(2, true); + + ActorProfileLibrary other; + other.loadFromJson(lib.toJson()); + + QCOMPARE(other.profileSlots(), QStringList({"Main", "Solo"})); + QCOMPARE(other.actorCount(), 1); + QVERIFY(other.isBackup(2)); + const Actor* loaded = other.actorForChannel(2); + QVERIFY(loaded != nullptr); + QCOMPARE(loaded->name(), QString("Dana")); + QVERIFY(loaded->profile("Solo").main().eqOn.value_or(false)); + } + + // --- Cue: channelProfiles / channelLevels ----------------------------- + void cue_channelProfilesAndLevels_roundTrip() { + Cue cue(1.0, "Top"); + cue.setChannelProfile(5, "Main"); + cue.setChannelProfile(6, "Solo"); + cue.setChannelLevel(5, 0.75); + + Cue r = Cue::fromJson(cue.toJson()); + QCOMPARE(r.channelProfiles().value(5), QString("Main")); + QCOMPARE(r.channelProfiles().value(6), QString("Solo")); + QCOMPARE(r.channelLevels().value(5), 0.75); + QVERIFY(!r.channelLevels().contains(6)); + } + + // --- Show: 1.2 persistence + 1.1 back-compat -------------------------- + void show_persistsActors_andMarksDirty() { + Show show; + QSignalSpy spy(&show, &Show::modifiedChanged); + Actor a("Eve", 4); + show.actorProfileLibrary()->addActor(a); + QVERIFY(show.isModified()); // library change propagates dirty + QVERIFY(spy.count() >= 1); + + const QJsonObject json = show.toJson(); + QCOMPARE(json["version"].toString(), QString("1.2")); + + Show loaded; + loaded.fromJson(json); + QCOMPARE(loaded.actorProfileLibrary()->actorCount(), 1); + QCOMPARE(loaded.actorProfileLibrary()->actorForChannel(4)->name(), QString("Eve")); + } + + void show_loadsLegacy_1_1_withoutActors() { + // a 1.1 document has no "actors" key; load must succeed with an empty library + QJsonObject legacy; + legacy["version"] = "1.1"; + legacy["name"] = "Legacy"; + legacy["cues"] = QJsonArray(); + + Show show; + show.actorProfileLibrary()->addActor(Actor("Stale", 9)); // pre-existing state + show.fromJson(legacy); + + QCOMPARE(show.name(), QString("Legacy")); + QCOMPARE(show.actorProfileLibrary()->actorCount(), 0); // cleared on load + QVERIFY(!show.isModified()); + } +}; + +QTEST_MAIN(TestActorProfiles) +#include "test_actor_profiles.moc" diff --git a/tests/test_fade_engine.cpp b/tests/test_fade_engine.cpp new file mode 100644 index 0000000..7a1eef0 --- /dev/null +++ b/tests/test_fade_engine.cpp @@ -0,0 +1,79 @@ +#include "core/FadeEngine.h" +#include +#include + +using namespace OpenMix; + +class TestFadeEngine : public QObject { + Q_OBJECT + + private slots: + void linearFade_interpolatesAndCompletes() { + FadeEngine fe; + QVector vals; + QSignalSpy done(&fe, &FadeEngine::allCompleted); + + fe.start("x", 0.0, 1.0, 100.0, FadeCurve::Linear, [&](double v) { vals.append(v); }); + QVERIFY(!vals.isEmpty()); + QCOMPARE(vals.last(), 0.0); // 'from' applied immediately + QVERIFY(fe.isActive()); + + fe.advance(50.0); + QVERIFY(qAbs(vals.last() - 0.5) < 1e-9); + + fe.advance(50.0); + QVERIFY(qAbs(vals.last() - 1.0) < 1e-9); + QVERIFY(!fe.isActive()); + QCOMPARE(done.count(), 1); + } + + void instantFade_appliesTargetImmediately() { + FadeEngine fe; + double v = -1.0; + fe.start("x", 0.0, 1.0, 0.0, FadeCurve::Linear, [&](double x) { v = x; }); + QCOMPARE(v, 1.0); + QVERIFY(!fe.isActive()); + } + + void easeInOut_isSmoothAndSymmetric() { + QCOMPARE(FadeEngine::ease(FadeCurve::EaseInOut, 0.0), 0.0); + QCOMPARE(FadeEngine::ease(FadeCurve::EaseInOut, 1.0), 1.0); + QVERIFY(qAbs(FadeEngine::ease(FadeCurve::EaseInOut, 0.5) - 0.5) < 1e-9); + } + + void curve_clampsOutOfRangeT() { + QCOMPARE(FadeEngine::ease(FadeCurve::Linear, -1.0), 0.0); + QCOMPARE(FadeEngine::ease(FadeCurve::Linear, 2.0), 1.0); + } + + void restart_replacesInflightFade() { + FadeEngine fe; + double v = -1.0; + fe.start("x", 0.0, 1.0, 100.0, FadeCurve::Linear, [&](double x) { v = x; }); + fe.advance(50.0); // ~0.5 + fe.start("x", 0.0, 0.2, 100.0, FadeCurve::Linear, [&](double x) { v = x; }); + QCOMPARE(fe.activeCount(), 1); // still one fade for key "x" + QCOMPARE(v, 0.0); // restart applies new 'from' + fe.advance(100.0); + QVERIFY(qAbs(v - 0.2) < 1e-9); + } + + void overshoot_clampsToTarget() { + FadeEngine fe; + double v = -1.0; + fe.start("x", 0.0, 1.0, 100.0, FadeCurve::Linear, [&](double x) { v = x; }); + fe.advance(5000.0); // way past the end + QVERIFY(qAbs(v - 1.0) < 1e-9); + QVERIFY(!fe.isActive()); + } + + void curveRoundTrip_stringConversion() { + for (FadeCurve c : {FadeCurve::Linear, FadeCurve::EaseInOut, FadeCurve::EaseIn, + FadeCurve::EaseOut}) { + QCOMPARE(stringToFadeCurve(fadeCurveToString(c)), c); + } + } +}; + +QTEST_MAIN(TestFadeEngine) +#include "test_fade_engine.moc" diff --git a/tests/test_msc.cpp b/tests/test_msc.cpp new file mode 100644 index 0000000..2784ae0 --- /dev/null +++ b/tests/test_msc.cpp @@ -0,0 +1,61 @@ +#include "midi/MscParser.h" +#include + +using namespace OpenMix; + +class TestMsc : public QObject { + Q_OBJECT + + private slots: + void parsesGoWithCueNumber() { + // F0 7F 7F 02 01 01 '1' '.' '5' 00 F7 -> GO cue 1.5, all devices + std::vector m = {0xF0, 0x7F, 0x7F, 0x02, 0x01, Msc::GO, + '1', '.', '5', 0x00, 0xF7}; + MscMessage r = parseMsc(m); + QVERIFY(r.valid); + QCOMPARE(r.command, Msc::GO); + QCOMPARE(r.deviceId, 0x7F); + QVERIFY(r.cueNumber.has_value()); + QVERIFY(qAbs(*r.cueNumber - 1.5) < 1e-9); + } + + void parsesGoWithoutCueNumber() { + std::vector m = {0xF0, 0x7F, 0x05, 0x02, 0x01, Msc::GO, 0xF7}; + MscMessage r = parseMsc(m); + QVERIFY(r.valid); + QCOMPARE(r.command, Msc::GO); + QCOMPARE(r.deviceId, 5); + QVERIFY(!r.cueNumber.has_value()); + } + + void parsesStop() { + std::vector m = {0xF0, 0x7F, 0x00, 0x02, 0x01, Msc::STOP, 0xF7}; + MscMessage r = parseMsc(m); + QVERIFY(r.valid); + QCOMPARE(r.command, Msc::STOP); + } + + void rejectsNonMscSysex() { + // a non-MSC universal sysex (sub-id != 0x02) + std::vector m = {0xF0, 0x7F, 0x00, 0x06, 0x01, 0x01, 0xF7}; + QVERIFY(!parseMsc(m).valid); + + // too short + std::vector tooShort = {0xF0, 0x7F, 0x00}; + QVERIFY(!parseMsc(tooShort).valid); + + // not a sysex at all + std::vector note = {0x90, 0x3C, 0x40}; + QVERIFY(!parseMsc(note).valid); + } + + void parsesIntegerCueNumber() { + std::vector m = {0xF0, 0x7F, 0x7F, 0x02, 0x01, Msc::GO, '4', '2', 0x00, 0xF7}; + MscMessage r = parseMsc(m); + QVERIFY(r.cueNumber.has_value()); + QVERIFY(qAbs(*r.cueNumber - 42.0) < 1e-9); + } +}; + +QTEST_MAIN(TestMsc) +#include "test_msc.moc" diff --git a/tests/test_osc_remote.cpp b/tests/test_osc_remote.cpp new file mode 100644 index 0000000..6883533 --- /dev/null +++ b/tests/test_osc_remote.cpp @@ -0,0 +1,72 @@ +#include "app/OscRemoteServer.h" +#include +#include +#include +#include + +using namespace OpenMix; + +namespace { +lo_address addrFor(const OscRemoteServer& server) { + return lo_address_new("127.0.0.1", QByteArray::number(server.port()).constData()); +} +} // namespace + +class TestOscRemote : public QObject { + Q_OBJECT + + private slots: + void startsOnFreePort() { + OscRemoteServer server; + QVERIFY(server.start(0)); + QVERIFY(server.port() > 0); + QVERIFY(server.isRunning()); + server.stop(); + QVERIFY(!server.isRunning()); + } + + void cueGoEmitsGoRequested() { + OscRemoteServer server; + QVERIFY(server.start(0)); + QSignalSpy spy(&server, &OscRemoteServer::goRequested); + + lo_address addr = addrFor(server); + lo_send(addr, "/cue/go", ""); + QVERIFY(spy.wait(2000)); + QCOMPARE(spy.count(), 1); + lo_address_free(addr); + } + + void gotoCarriesCueNumber() { + OscRemoteServer server; + QVERIFY(server.start(0)); + QSignalSpy spy(&server, &OscRemoteServer::gotoRequested); + + lo_address addr = addrFor(server); + lo_send(addr, "/cue/goto", "f", 12.5f); + QVERIFY(spy.wait(2000)); + QCOMPARE(spy.count(), 1); + QVERIFY(qAbs(spy.at(0).at(0).toDouble() - 12.5) < 1e-6); + lo_address_free(addr); + } + + void fadeAllAndStopAndNext() { + OscRemoteServer server; + QVERIFY(server.start(0)); + QSignalSpy fade(&server, &OscRemoteServer::fadeAllRequested); + QSignalSpy stop(&server, &OscRemoteServer::stopRequested); + QSignalSpy next(&server, &OscRemoteServer::nextRequested); + + lo_address addr = addrFor(server); + lo_send(addr, "/ctrl/fadeall", ""); + QVERIFY(fade.wait(2000)); + lo_send(addr, "/ctrl/stop", ""); + QVERIFY(stop.wait(2000)); + lo_send(addr, "/next", ""); + QVERIFY(next.wait(2000)); + lo_address_free(addr); + } +}; + +QTEST_MAIN(TestOscRemote) +#include "test_osc_remote.moc" diff --git a/tests/test_playback_profiles.cpp b/tests/test_playback_profiles.cpp new file mode 100644 index 0000000..bb0ab09 --- /dev/null +++ b/tests/test_playback_profiles.cpp @@ -0,0 +1,186 @@ +#include "core/Actor.h" +#include "core/ActorProfile.h" +#include "core/ActorProfileLibrary.h" +#include "core/Cue.h" +#include "core/CueList.h" +#include "core/FadeEngine.h" +#include "core/PlaybackEngine.h" +#include "protocol/LoopbackProtocol.h" +#include "protocol/MixerCapabilities.h" +#include +#include + +using namespace OpenMix; + +namespace { +LoopbackProtocol* makeConnectedLoopback(QObject* parent) { + auto* mixer = new LoopbackProtocol(MixerCapabilities::forConsole(ConsoleType::X32), parent); + mixer->connect("loopback", 0); // sets state Connected synchronously + return mixer; +} +} // namespace + +class TestPlaybackProfiles : public QObject { + Q_OBJECT + + private slots: + void firingCue_appliesActorVoiceAndLevel() { + ActorProfileLibrary lib; + Actor a("Lead", 5); + ActorProfile p; + p.main().gainDb = 6.0; + p.main().hpfOn = true; + p.main().hpfFreq = 90.0; + p.main().eqOn = true; + p.main().eqBands.append(EqBand{2, true, 2, 2500.0, -2.0, 1.5}); + a.setProfile("Main", p); + lib.addActor(a); + + CueList cues; + Cue cue(1.0, "Top"); + cue.setType(CueType::Snapshot); + cue.setChannelProfile(5, "Main"); + cue.setChannelLevel(5, 0.5); + cues.addCue(cue); + + LoopbackProtocol* mixer = makeConnectedLoopback(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setActorLibrary(&lib); + engine.setMixer(mixer); + engine.executeCue(0); + + const QStringList calls = mixer->recordedCalls(); + QVERIFY2(calls.contains("preamp:ch=5:gain=6"), qPrintable(calls.join(" | "))); + QVERIFY(calls.contains("hpf:ch=5:on=1:freq=90")); + QVERIFY(calls.contains("eqon:ch=5:on=1")); + QVERIFY(calls.contains("fader:ch=5:level=0.5")); + + bool sawEqBand = false; + for (const QString& c : calls) { + if (c.startsWith("eqband:ch=5:band=2")) + sawEqBand = true; + } + QVERIFY(sawEqBand); + } + + void firingCue_backupChannel_appliesBackupVoice() { + ActorProfileLibrary lib; + Actor a("Lead", 7); + ActorProfile p; + p.main().gainDb = 3.0; + p.backup().gainDb = -10.0; + a.setProfile("Main", p); + lib.addActor(a); + lib.setBackup(7, true); // channel on its spare mic + + CueList cues; + Cue cue(1.0, "Top"); + cue.setType(CueType::Snapshot); + cue.setChannelProfile(7, "Main"); + cues.addCue(cue); + + LoopbackProtocol* mixer = makeConnectedLoopback(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setActorLibrary(&lib); + engine.setMixer(mixer); + engine.executeCue(0); + + QVERIFY(mixer->recordedCalls().contains("preamp:ch=7:gain=-10")); + QVERIFY(!mixer->recordedCalls().contains("preamp:ch=7:gain=3")); + } + + void firingCue_noActorOnChannel_appliesNoVoice() { + ActorProfileLibrary lib; // empty cast + CueList cues; + Cue cue(1.0, "Top"); + cue.setType(CueType::Snapshot); + cue.setChannelProfile(3, "Main"); + cues.addCue(cue); + + LoopbackProtocol* mixer = makeConnectedLoopback(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setActorLibrary(&lib); + engine.setMixer(mixer); + engine.executeCue(0); + + for (const QString& c : mixer->recordedCalls()) + QVERIFY(!c.startsWith("preamp:ch=3")); + } + + void instantCue_setsChannelLevelImmediately() { + CueList cues; + Cue a(1.0, "A"); + a.setType(CueType::Snapshot); + a.setChannelLevel(5, 0.6); + cues.addCue(a); + + LoopbackProtocol* mixer = makeConnectedLoopback(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setMixer(mixer); + engine.executeCue(0); + + QVERIFY(mixer->recordedCalls().contains("fader:ch=5:level=0.6")); + QVERIFY(!engine.fadeEngine()->isActive()); + } + + void fadeCue_fadesChannelLevelFromPriorValue() { + CueList cues; + Cue a(1.0, "A"); + a.setType(CueType::Snapshot); + a.setChannelLevel(5, 0.0); + Cue b(2.0, "B"); + b.setType(CueType::Snapshot); + b.setChannelLevel(5, 1.0); + b.setFadeTime(2.0); + b.setFadeCurve(FadeCurve::Linear); + cues.addCue(a); + cues.addCue(b); + + LoopbackProtocol* mixer = makeConnectedLoopback(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setMixer(mixer); + + engine.executeCue(0); // A: instant, sets applied level to 0 + mixer->clearRecordedCalls(); + + engine.executeCue(1); // B: fade from 0 -> applies 'from' (0) now, not target yet + QVERIFY(mixer->recordedCalls().contains("fader:ch=5:level=0")); + QVERIFY(!mixer->recordedCalls().contains("fader:ch=5:level=1")); + QVERIFY(engine.fadeEngine()->isActive()); + + engine.fadeEngine()->advance(2000.0); // run the fade to completion + QVERIFY(mixer->recordedCalls().contains("fader:ch=5:level=1")); + QVERIFY(!engine.fadeEngine()->isActive()); + } + + void verifyCue_emitsLanded_whenParamsMatch() { + CueList cues; + Cue a(1.0, "A"); + a.setType(CueType::Snapshot); + a.setParameter("/ch/01/mix/fader", 0.5); + cues.addCue(a); + + LoopbackProtocol* mixer = makeConnectedLoopback(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setMixer(mixer); + engine.setVerifyCues(true); + + QSignalSpy landed(&engine, &PlaybackEngine::cueLanded); + QSignalSpy drifted(&engine, &PlaybackEngine::cueDrifted); + + engine.executeCue(0); + + QVERIFY(landed.wait(1000)); // loopback echoes the value back asynchronously + QCOMPARE(landed.count(), 1); + QCOMPARE(drifted.count(), 0); + } +}; + +QTEST_MAIN(TestPlaybackProfiles) +#include "test_playback_profiles.moc" diff --git a/tests/test_qlab_client.cpp b/tests/test_qlab_client.cpp new file mode 100644 index 0000000..d705a67 --- /dev/null +++ b/tests/test_qlab_client.cpp @@ -0,0 +1,75 @@ +#include "app/QLabClient.h" +#include +#include +#include +#include + +using namespace OpenMix; + +namespace { +struct Recorder { + QStringList paths; +}; +int record(const char* path, const char*, lo_arg**, int, lo_message, void* user) { + static_cast(user)->paths.append(QString::fromUtf8(path)); + return 0; +} +} // namespace + +class TestQLabClient : public QObject { + Q_OBJECT + + private slots: + void triggerCueSendsStart() { + Recorder rec; + lo_server_thread srv = lo_server_thread_new(nullptr, nullptr); + QVERIFY(srv); + lo_server_thread_add_method(srv, nullptr, nullptr, record, &rec); + lo_server_thread_start(srv); + + QLabClient client; + client.setTarget("127.0.0.1", lo_server_thread_get_port(srv)); + client.setEnabled(true); + + QSignalSpy sent(&client, &QLabClient::sent); + client.triggerCue("5"); // pre-roll 0 -> emitted synchronously + + QCOMPARE(sent.count(), 1); + QCOMPARE(sent.at(0).at(0).toString(), QString("/cue/5/start")); + QTRY_VERIFY(rec.paths.contains("/cue/5/start")); // UDP receipt is async + + lo_server_thread_stop(srv); + lo_server_thread_free(srv); + } + + void disabledClientSendsNothing() { + QLabClient client; + client.setEnabled(false); + QSignalSpy sent(&client, &QLabClient::sent); + client.triggerCue("3"); + QTest::qWait(50); + QCOMPARE(sent.count(), 0); + } + + void workspacePrefixedAddress() { + Recorder rec; + lo_server_thread srv = lo_server_thread_new(nullptr, nullptr); + QVERIFY(srv); + lo_server_thread_add_method(srv, nullptr, nullptr, record, &rec); + lo_server_thread_start(srv); + + QLabClient client; + client.setTarget("127.0.0.1", lo_server_thread_get_port(srv)); + client.setWorkspaceId("abc"); + client.setEnabled(true); + client.triggerCue("12"); + + QTRY_VERIFY(rec.paths.contains("/workspace/abc/cue/12/start")); + + lo_server_thread_stop(srv); + lo_server_thread_free(srv); + } +}; + +QTEST_MAIN(TestQLabClient) +#include "test_qlab_client.moc" diff --git a/tests/test_yamaha_osc.cpp b/tests/test_yamaha_osc.cpp deleted file mode 100644 index afcf795..0000000 --- a/tests/test_yamaha_osc.cpp +++ /dev/null @@ -1,80 +0,0 @@ -#include "protocol/yamaha/YamahaProtocol.h" -#include -#include - -using namespace OpenMix; - -class TestYamahaProtocol : public YamahaProtocol { - Q_OBJECT - public: - explicit TestYamahaProtocol(QObject* parent = nullptr) - : YamahaProtocol(MixerCapabilities{}, parent) {} - - void parseOscData(const QByteArray& data) { parseOscMessage(data); } - - protected: - void initializeSnapshotParams() override {} -}; - -// builds an OSC message with a single blob argument whose declared size is negative. -// path is 4-byte aligned; type tag ",b" is 4-byte aligned; then 4 bytes of big-endian int32. -static QByteArray makeMalformedBlobMessage(const QString& path, qint32 blobSize) { - QByteArray data; - - QByteArray pathBytes = path.toUtf8(); - pathBytes.append('\0'); - while (pathBytes.size() % 4 != 0) - pathBytes.append('\0'); - data.append(pathBytes); - - QByteArray typeTag(",b\0", 3); - while (typeTag.size() % 4 != 0) - typeTag.append('\0'); - data.append(typeTag); - - data.append(static_cast((blobSize >> 24) & 0xFF)); - data.append(static_cast((blobSize >> 16) & 0xFF)); - data.append(static_cast((blobSize >> 8) & 0xFF)); - data.append(static_cast(blobSize & 0xFF)); - - return data; -} - -class TestYamahaOsc : public QObject { - Q_OBJECT - - private slots: - void negativeBlobSize_doesNotEmitParameterChanged() { - TestYamahaProtocol proto; - QSignalSpy spy(&proto, &TestYamahaProtocol::parameterChanged); - - proto.parseOscData(makeMalformedBlobMessage("/test/blob", -1)); - - QCOMPARE(spy.count(), 0); - } - - void validFloat_emitsParameterChanged() { - TestYamahaProtocol proto; - QSignalSpy spy(&proto, &TestYamahaProtocol::parameterChanged); - - // build valid OSC float message: path "/dca/1/fader", type 'f', value 0.75 - QByteArray data; - QByteArray pathBytes("/dca/1/fader\0\0\0\0", 16); // 12 chars + null + 3 pads = 16 - data.append(pathBytes); - data.append(",f\0\0", 4); // type tag - // 0.75f as big-endian - quint32 bits; - float v = 0.75f; - std::memcpy(&bits, &v, 4); - quint32 be = qToBigEndian(bits); - data.append(reinterpret_cast(&be), 4); - - proto.parseOscData(data); - - QCOMPARE(spy.count(), 1); - QVERIFY(qAbs(spy.at(0).at(1).toFloat() - 0.75f) < 1e-6f); - } -}; - -QTEST_MAIN(TestYamahaOsc) -#include "test_yamaha_osc.moc" diff --git a/tests/test_yamaha_scp.cpp b/tests/test_yamaha_scp.cpp new file mode 100644 index 0000000..056d627 --- /dev/null +++ b/tests/test_yamaha_scp.cpp @@ -0,0 +1,190 @@ +#include "protocol/yamaha/YamahaDM7Protocol.h" +#include "protocol/yamaha/YamahaProtocol.h" +#include +#include + +using namespace OpenMix; + +// Exposes the protected SCP line parser for testing. +class ScpProbe : public YamahaProtocol { + public: + explicit ScpProbe(QObject* parent = nullptr) + : YamahaProtocol(MixerCapabilities::forConsole(ConsoleType::CL5), parent) {} + + void feed(const QByteArray& line) { parseScpLine(line); } +}; + +class TestYamahaScp : public QObject { + Q_OBJECT + + private slots: + // ---- pure command framing (exact ASCII strings) ---- + + void scpSet_exactString() { + QCOMPARE(YamahaProtocol::scpSet("MIXER:Current/InCh/Fader/Level", 0, 0, 1000), + QByteArray("set MIXER:Current/InCh/Fader/Level 0 0 1000\n")); + // negative (centi-dB) value + QCOMPARE(YamahaProtocol::scpSet("MIXER:Current/InCh/Fader/Level", 5, 0, -32768), + QByteArray("set MIXER:Current/InCh/Fader/Level 5 0 -32768\n")); + } + + void scpSet_stringValueIsQuoted() { + QCOMPARE(YamahaProtocol::scpSet("MIXER:Current/InCh/Label/Name", 2, 0, QString("Vox")), + QByteArray("set MIXER:Current/InCh/Label/Name 2 0 \"Vox\"\n")); + } + + void scpGet_exactString() { + QCOMPARE(YamahaProtocol::scpGet("MIXER:Current/InCh/Fader/Level", 0, 0), + QByteArray("get MIXER:Current/InCh/Fader/Level 0 0\n")); + } + + void scpSceneRecall_exactString() { + 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 faderTaper_monotonic() { + int prev = YamahaProtocol::faderLevelToScp(0.01); + for (double l = 0.05; l <= 1.0; l += 0.05) { + int cur = YamahaProtocol::faderLevelToScp(l); + QVERIFY2(cur >= prev, qPrintable(QString("non-monotonic at %1").arg(l))); + prev = cur; + } + } + + void scaling_helpers() { + QCOMPARE(YamahaProtocol::dbToCentiDb(-10.0), -1000); + QCOMPARE(YamahaProtocol::dbToCentiDb(10.0), 1000); + QCOMPARE(YamahaProtocol::hzToScpFreq(100.0), 1000); // Hz * 10 + QCOMPARE(YamahaProtocol::hzToScpFreq(80.0), 800); + QCOMPARE(YamahaProtocol::qToScp(1.4), 1400); // Q * 1000 + } + + // ---- semantic builders (channel index is 0-based) ---- + + void buildFader_usesZeroBasedIndex() { + ScpProbe p; + QCOMPARE(p.buildChannelFader(0, 0.75), + QByteArray("set MIXER:Current/InCh/Fader/Level 0 0 0\n")); + QCOMPARE(p.buildChannelFader(3, 1.0), + QByteArray("set MIXER:Current/InCh/Fader/Level 3 0 1000\n")); + } + + void buildMute_onMeansChannelOff() { + ScpProbe p; + // muted -> Fader/On 0 + QCOMPARE(p.buildChannelMute(2, true), + QByteArray("set MIXER:Current/InCh/Fader/On 2 0 0\n")); + // unmuted -> Fader/On 1 + QCOMPARE(p.buildChannelMute(2, false), + QByteArray("set MIXER:Current/InCh/Fader/On 2 0 1\n")); + } + + void buildPreamp_clUsesCentiDb() { + ScpProbe p; // CL caps + QCOMPARE(p.buildChannelPreamp(0, 30.0), + QByteArray("set MIXER:Current/InCh/Port/HA/Gain 0 0 3000\n")); + } + + void buildPreamp_dm7UsesWholeDb() { + YamahaDM7Protocol dm7(MixerCapabilities::forConsole(ConsoleType::DM7)); + QCOMPARE(dm7.buildChannelPreamp(0, 30.0), + QByteArray("set MIXER:Current/InCh/Port/HA/Gain 0 0 30\n")); + } + + void buildHpf() { + ScpProbe p; + 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")); + // 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")); + QCOMPARE(p.buildChannelEqBandBypass(0, 1, false), + QByteArray("set MIXER:Current/InCh/PEQ/Band/Bypass 0 1 1\n")); + QCOMPARE(p.buildChannelEqBandFreq(0, 1, 1000.0), + QByteArray("set MIXER:Current/InCh/PEQ/Band/Freq 0 1 10000\n")); + QCOMPARE(p.buildChannelEqBandGain(0, 1, 6.0), + QByteArray("set MIXER:Current/InCh/PEQ/Band/Gain 0 1 600\n")); + QCOMPARE(p.buildChannelEqBandQ(0, 1, 1.4), + QByteArray("set MIXER:Current/InCh/PEQ/Band/Q 0 1 1400\n")); + } + + void buildEqBandGain_clamps() { + ScpProbe p; + QCOMPARE(p.buildChannelEqBandGain(0, 0, 100.0), // way over +18 dB + QByteArray("set MIXER:Current/InCh/PEQ/Band/Gain 0 0 1800\n")); + } + + void buildDynamicsThreshold() { + ScpProbe p; + QCOMPARE(p.buildChannelDynamicsThreshold(0, -26.0), + QByteArray("set MIXER:Current/InCh/Dyna2/Threshold 0 0 -260\n")); + } + + // ---- incoming line parsing ---- + + void parseNotify_emitsParameterChanged() { + ScpProbe p; + QSignalSpy spy(&p, &MixerProtocol::parameterChanged); + + p.feed("NOTIFY set \"MIXER:Current/InCh/Fader/Level\" 0 0 1000"); + + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(0).toString(), QString("MIXER:Current/InCh/Fader/Level")); + QCOMPARE(spy.at(0).at(1).value().toInt(), 1000); + } + + void parseOkSet_emitsParameterChanged() { + ScpProbe p; + QSignalSpy spy(&p, &MixerProtocol::parameterChanged); + + p.feed("OK set \"MIXER:Current/InCh/Fader/On\" 4 0 0"); + + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(0).toString(), QString("MIXER:Current/InCh/Fader/On")); + QCOMPARE(spy.at(0).at(1).value().toInt(), 0); + } + + void parseDevinfo_doesNotEmitParameterChanged() { + ScpProbe p; + QSignalSpy spy(&p, &MixerProtocol::parameterChanged); + + p.feed("OK devinfo productname \"CL5\""); + + QCOMPARE(spy.count(), 0); + QVERIFY(p.connectionStatus().contains("CL5")); + } + + void parseError_isIgnored() { + ScpProbe p; + QSignalSpy spy(&p, &MixerProtocol::parameterChanged); + + p.feed("ERROR set \"MIXER:Current/InCh/Fader/Level\" 0 0 1000"); + + QCOMPARE(spy.count(), 0); + } +}; + +QTEST_MAIN(TestYamahaScp) +#include "test_yamaha_scp.moc" From 4969cfd0a769ed5a0269ed9284e8be2fb818f81b Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Tue, 30 Jun 2026 18:58:44 -0400 Subject: [PATCH 02/92] feat: Allen & Heath input-channel fader control via NRPN --- .../allenheath/AllenHeathMidiProtocol.cpp | 21 +++++++++++++++++++ .../allenheath/AllenHeathMidiProtocol.h | 10 +++++++++ src/protocol/allenheath/SQProtocol.cpp | 5 +++++ 3 files changed, 36 insertions(+) diff --git a/src/protocol/allenheath/AllenHeathMidiProtocol.cpp b/src/protocol/allenheath/AllenHeathMidiProtocol.cpp index fb4d220..e8a454f 100644 --- a/src/protocol/allenheath/AllenHeathMidiProtocol.cpp +++ b/src/protocol/allenheath/AllenHeathMidiProtocol.cpp @@ -83,11 +83,32 @@ void AllenHeathMidiProtocol::sendParameter(const QString& path, const QVariant& m_transport.send(msg); } } + } else if (path.startsWith("/ch/")) { + QStringList parts = path.split('/'); + if (parts.size() >= 4) { + int ch = parts[2].toInt(); + QString param = parts[3]; + + if (param == "fader") { + // input-channel fader: 14-bit NRPN, same value scaling as DCA. + // CH_FADER_NRPN_MSB is a placeholder — see the note in the header. + int midiValue = qBound(0, static_cast(value.toFloat() * 16383.0f), 16383); + int msb = (midiValue >> 7) & 0x7F; + int lsb = midiValue & 0x7F; + QByteArray msg = buildNRPNMessage(0, CH_FADER_NRPN_MSB, ch - 1, msb, lsb); + m_transport.send(msg); + } + } } m_parameterCache[path] = value; } +void AllenHeathMidiProtocol::setChannelFader(int channel, double level) { + // route through the path-based encoder above (keeps one wire-format code path) + sendParameter(QString("/ch/%1/fader").arg(channel), static_cast(level)); +} + QVariant AllenHeathMidiProtocol::getParameter(const QString& path) { return m_parameterCache.value(path); } diff --git a/src/protocol/allenheath/AllenHeathMidiProtocol.h b/src/protocol/allenheath/AllenHeathMidiProtocol.h index 075928a..0eeeb69 100644 --- a/src/protocol/allenheath/AllenHeathMidiProtocol.h +++ b/src/protocol/allenheath/AllenHeathMidiProtocol.h @@ -69,7 +69,17 @@ class AllenHeathMidiProtocol : public MixerProtocol { // capabilities [[nodiscard]] const MixerCapabilities& capabilities() const override { return m_capabilities; } + // Input-channel fader level via NRPN (used by actor-voice levels + fades). + // NOTE: CH_FADER_NRPN_MSB is a placeholder for the input-channel -> LR level + // parameter MSB; set it from the A&H SQ MIDI Protocol Issue 5 reference tables + // before driving real hardware: + // https://www.allen-heath.com/content/uploads/2023/11/SQ-MIDI-Protocol-Issue5.pdf + // Channel mute over A&H MIDI uses MIDI notes and is left unimplemented here. + void setChannelFader(int channel, double level) override; + protected: + static constexpr int CH_FADER_NRPN_MSB = 0x40; // placeholder — verify vs SQ MIDI tables + // MIDI message builders used by subclasses QByteArray buildNRPNMessage(int channel, int nrpnMsb, int nrpnLsb, int valueMsb, int valueLsb); QByteArray buildSysExSceneRecall(int sceneNumber); diff --git a/src/protocol/allenheath/SQProtocol.cpp b/src/protocol/allenheath/SQProtocol.cpp index 339c6ce..fcddc75 100644 --- a/src/protocol/allenheath/SQProtocol.cpp +++ b/src/protocol/allenheath/SQProtocol.cpp @@ -14,6 +14,11 @@ void SQProtocol::initializeSnapshotParams() { m_snapshotParams.append(dcaFaderPath(i)); m_snapshotParams.append(dcaMutePath(i)); } + + // 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)); + } } QString SQProtocol::dcaFaderPath(int dca) const { return QString("/dca/%1/fader").arg(dca); } From c05e5a6094ed0a4eb35f843be7cfe83e2ebe1c3d Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Tue, 30 Jun 2026 19:12:33 -0400 Subject: [PATCH 03/92] feat: positions, Cue Zero, MTC timecode, channel monitoring --- CMakeLists.txt | 9 +++ src/core/ChannelMonitor.cpp | 95 ++++++++++++++++++++++ src/core/ChannelMonitor.h | 73 +++++++++++++++++ src/core/Cue.cpp | 25 ++++++ src/core/Cue.h | 14 ++++ src/core/CueZero.cpp | 86 ++++++++++++++++++++ src/core/CueZero.h | 57 +++++++++++++ src/core/PlaybackEngine.cpp | 39 +++++++++ src/core/PlaybackEngine.h | 4 + src/core/Position.cpp | 136 ++++++++++++++++++++++++++++++++ src/core/Position.h | 96 ++++++++++++++++++++++ src/core/Show.cpp | 31 +++++++- src/core/Show.h | 17 ++++ src/core/TimecodeTrigger.cpp | 134 +++++++++++++++++++++++++++++++ src/core/TimecodeTrigger.h | 75 ++++++++++++++++++ src/midi/MidiInputManager.cpp | 16 +++- src/midi/MidiInputManager.h | 5 ++ src/midi/MtcParser.h | 93 ++++++++++++++++++++++ tests/CMakeLists.txt | 50 ++++++++++++ tests/test_channel_monitor.cpp | 92 +++++++++++++++++++++ tests/test_cuezero.cpp | 89 +++++++++++++++++++++ tests/test_mtc.cpp | 88 +++++++++++++++++++++ tests/test_position.cpp | 114 ++++++++++++++++++++++++++ tests/test_timecode_trigger.cpp | 88 +++++++++++++++++++++ 24 files changed, 1523 insertions(+), 3 deletions(-) create mode 100644 src/core/ChannelMonitor.cpp create mode 100644 src/core/ChannelMonitor.h create mode 100644 src/core/CueZero.cpp create mode 100644 src/core/CueZero.h create mode 100644 src/core/Position.cpp create mode 100644 src/core/Position.h create mode 100644 src/core/TimecodeTrigger.cpp create mode 100644 src/core/TimecodeTrigger.h create mode 100644 src/midi/MtcParser.h create mode 100644 tests/test_channel_monitor.cpp create mode 100644 tests/test_cuezero.cpp create mode 100644 tests/test_mtc.cpp create mode 100644 tests/test_position.cpp create mode 100644 tests/test_timecode_trigger.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 73fd8af..2cd2afc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -96,6 +96,10 @@ set(SOURCES src/core/Actor.cpp src/core/ActorProfileLibrary.cpp src/core/FadeEngine.cpp + src/core/Position.cpp + src/core/CueZero.cpp + src/core/TimecodeTrigger.cpp + src/core/ChannelMonitor.cpp src/core/AppLogger.cpp src/core/ConnectionLogBridge.cpp src/ui/MainWindow.cpp @@ -183,6 +187,10 @@ set(HEADERS src/core/ActorProfileLibrary.h src/core/FadeCurve.h src/core/FadeEngine.h + src/core/Position.h + src/core/CueZero.h + src/core/TimecodeTrigger.h + src/core/ChannelMonitor.h src/core/AppLogger.h src/core/ConnectionLogBridge.h src/ui/MainWindow.h @@ -243,6 +251,7 @@ set(HEADERS src/midi/MidiControlMapping.h src/midi/MidiInputManager.h src/midi/MscParser.h + src/midi/MtcParser.h src/ui/MidiConfigDialog.h src/ui/KeyboardShortcutsDialog.h src/ui/LogViewerDialog.h diff --git a/src/core/ChannelMonitor.cpp b/src/core/ChannelMonitor.cpp new file mode 100644 index 0000000..9493f04 --- /dev/null +++ b/src/core/ChannelMonitor.cpp @@ -0,0 +1,95 @@ +#include "ChannelMonitor.h" +#include + +namespace OpenMix { + +ChannelMonitor::ChannelMonitor(QObject* parent) : QObject(parent) {} + +ChannelMonitor::ChannelData& ChannelMonitor::ensureChannel(int channel) { + auto it = m_channels.find(channel); + if (it != m_channels.end()) { + return *it; + } + + ChannelData data; + data.silenceTimer = new QTimer(this); + data.silenceTimer->setSingleShot(true); + connect(data.silenceTimer, &QTimer::timeout, this, + [this, channel]() { setState(channel, ChannelState::Silent); }); + + data.clipTimer = new QTimer(this); + data.clipTimer->setSingleShot(true); + connect(data.clipTimer, &QTimer::timeout, this, + [this, channel]() { onClipHoldExpired(channel); }); + + return *m_channels.insert(channel, data); +} + +void ChannelMonitor::setState(int channel, ChannelState state) { + auto it = m_channels.find(channel); + if (it == m_channels.end()) { + return; + } + if (it->state != state) { + it->state = state; + emit channelStateChanged(channel, static_cast(state)); + } +} + +void ChannelMonitor::onClipHoldExpired(int channel) { + // clip ceiling no longer held: drop back to Normal; the next level samples + // re-evaluate (and may re-arm silence detection if the input has gone quiet) + setState(channel, ChannelState::Normal); +} + +void ChannelMonitor::onLevel(int channel, double level) { + ChannelData& data = ensureChannel(channel); + + // clipping takes priority and (re)starts the hold window + if (level >= m_clipThreshold) { + data.silenceTimer->stop(); + setState(channel, ChannelState::Clipping); + data.clipTimer->start(m_clipHoldMs); + return; + } + + // still inside the clip-hold window: keep Clipping until the timer expires + if (data.clipTimer->isActive()) { + return; + } + + if (level <= m_silenceThreshold) { + if (data.state == ChannelState::Silent) { + return; // already silent; nothing to do + } + // arm the silence timeout; state stays Normal until it elapses + if (!data.silenceTimer->isActive()) { + data.silenceTimer->start(m_silenceTimeoutMs); + } + } else { + // audio present in band: cancel any pending silence and return to Normal + data.silenceTimer->stop(); + setState(channel, ChannelState::Normal); + } +} + +ChannelState ChannelMonitor::channelState(int channel) const { + auto it = m_channels.constFind(channel); + return it != m_channels.constEnd() ? it->state : ChannelState::Normal; +} + +void ChannelMonitor::reset() { + for (ChannelData& data : m_channels) { + if (data.silenceTimer) { + data.silenceTimer->stop(); + data.silenceTimer->deleteLater(); + } + if (data.clipTimer) { + data.clipTimer->stop(); + data.clipTimer->deleteLater(); + } + } + m_channels.clear(); +} + +} // namespace OpenMix diff --git a/src/core/ChannelMonitor.h b/src/core/ChannelMonitor.h new file mode 100644 index 0000000..716cdc7 --- /dev/null +++ b/src/core/ChannelMonitor.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include + +class QTimer; + +namespace OpenMix { + +// Per-channel scribble-strip health, à la TheatreMix's silence/clip colouring. +enum class ChannelState { + Normal = 0, // audio present in band + Silent = 1, // below the silence floor for longer than the silence timeout + Clipping = 2 // hit/exceeded the clip ceiling (held briefly, then restored) +}; + +// Watches per-channel meter levels and raises a scribble-strip state per channel. +// Feed it metering via onLevel(); it runs a small state machine per channel: +// - level >= clipThreshold -> Clipping, held for clipHold, then restored +// - level <= silenceThreshold for -> Silent (after silenceTimeout elapses) +// longer than silenceTimeout +// - otherwise -> Normal +// Timeouts/thresholds are settable so tests (and rooms) can tune responsiveness. +class ChannelMonitor : public QObject { + Q_OBJECT + + public: + explicit ChannelMonitor(QObject* parent = nullptr); + + void setSilenceThreshold(double level) { m_silenceThreshold = level; } + [[nodiscard]] double silenceThreshold() const noexcept { return m_silenceThreshold; } + + void setClipThreshold(double level) { m_clipThreshold = level; } + [[nodiscard]] double clipThreshold() const noexcept { return m_clipThreshold; } + + // how long a channel must stay below the floor before it is declared Silent + void setSilenceTimeoutMs(int ms) { m_silenceTimeoutMs = ms; } + [[nodiscard]] int silenceTimeoutMs() const noexcept { return m_silenceTimeoutMs; } + + // how long Clipping is held after the last over-ceiling sample before restore + void setClipHoldMs(int ms) { m_clipHoldMs = ms; } + [[nodiscard]] int clipHoldMs() const noexcept { return m_clipHoldMs; } + + [[nodiscard]] ChannelState channelState(int channel) const; + + void reset(); // clear all per-channel state and timers + + public slots: + void onLevel(int channel, double level); + + signals: + void channelStateChanged(int channel, int state); + + private: + struct ChannelData { + ChannelState state = ChannelState::Normal; + QTimer* silenceTimer = nullptr; + QTimer* clipTimer = nullptr; + }; + + ChannelData& ensureChannel(int channel); + void setState(int channel, ChannelState state); + void onClipHoldExpired(int channel); + + double m_silenceThreshold = 0.05; + double m_clipThreshold = 0.97; + int m_silenceTimeoutMs = 4000; + int m_clipHoldMs = 1500; + + QHash m_channels; +}; + +} // namespace OpenMix diff --git a/src/core/Cue.cpp b/src/core/Cue.cpp index b4f2024..24d75a3 100644 --- a/src/core/Cue.cpp +++ b/src/core/Cue.cpp @@ -138,6 +138,14 @@ QVariant Cue::parameter(const QString& path) const { return QVariant(); } +void Cue::setChannelPosition(int channel, const QString& positionId) { + if (positionId.isEmpty()) { + m_channelPositions.remove(channel); + } else { + m_channelPositions[channel] = positionId; + } +} + void Cue::setDCAOverride(int dca, const DCAOverride& override) { if (override.hasOverrides()) { m_dcaOverrides[dca] = override; @@ -291,6 +299,15 @@ QJsonObject Cue::toJson() const { json["channelLevels"] = levelsObj; } + // named-position assignments + if (!m_channelPositions.isEmpty()) { + QJsonObject positionsObj; + for (auto it = m_channelPositions.constBegin(); it != m_channelPositions.constEnd(); ++it) { + positionsObj[QString::number(it.key())] = it.value(); + } + json["channelPositions"] = positionsObj; + } + return json; } @@ -398,6 +415,14 @@ Cue Cue::fromJson(const QJsonObject& json) { } } + // named-position assignments + if (json.contains("channelPositions")) { + QJsonObject positionsObj = json["channelPositions"].toObject(); + for (auto it = positionsObj.constBegin(); it != positionsObj.constEnd(); ++it) { + cue.m_channelPositions[it.key().toInt()] = it.value().toString(); + } + } + return cue; } diff --git a/src/core/Cue.h b/src/core/Cue.h index e071a7d..c34e7ca 100644 --- a/src/core/Cue.h +++ b/src/core/Cue.h @@ -151,6 +151,17 @@ class Cue { } void removeTag(const QString& tag) { m_tags.removeAll(tag); } + // 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 setChannelPosition(int channel, const QString& positionId); + [[nodiscard]] QString channelPosition(int channel) const { + return m_channelPositions.value(channel); + } + void clearChannelPosition(int channel) { m_channelPositions.remove(channel); } + void clearChannelPositions() { m_channelPositions.clear(); } + [[nodiscard]] QJsonObject parameters() const { return m_parameters; } void setParameters(const QJsonObject& params) { m_parameters = params; } void setParameter(const QString& path, const QVariant& value); @@ -210,6 +221,9 @@ class Cue { QStringList m_tags; QString m_qLabCue; // linked QLab cue id (DAW remote) + // channel# -> Position id (named-position assignments) + QMap m_channelPositions; + QJsonObject m_parameters; QMap m_channelProfiles; // channel -> active profile slot id diff --git a/src/core/CueZero.cpp b/src/core/CueZero.cpp new file mode 100644 index 0000000..72773ec --- /dev/null +++ b/src/core/CueZero.cpp @@ -0,0 +1,86 @@ +#include "CueZero.h" +#include "protocol/MixerProtocol.h" + +namespace OpenMix { + +CueZero::CueZero(QObject* parent) : QObject(parent) {} + +void CueZero::setBaseScene(int scene) { + if (m_baseScene != scene) { + m_baseScene = scene; + emit changed(); + } +} + +void CueZero::setLevels(const QJsonObject& levels) { + m_levels = levels; + emit changed(); +} + +void CueZero::setLevel(const QString& path, const QVariant& value) { + m_levels[path] = QJsonValue::fromVariant(value); + emit changed(); +} + +void CueZero::setLabels(const QJsonObject& labels) { + m_labels = labels; + emit changed(); +} + +void CueZero::setLabel(const QString& path, const QString& name) { + m_labels[path] = name; + emit changed(); +} + +bool CueZero::isEmpty() const { + return m_baseScene < 0 && m_levels.isEmpty() && m_labels.isEmpty(); +} + +void CueZero::clear() { + m_baseScene = -1; + m_levels = QJsonObject(); + m_labels = QJsonObject(); + emit changed(); +} + +void CueZero::apply(MixerProtocol* mixer) const { + if (!mixer || !mixer->isConnected()) { + return; + } + + // recall the base scene first so subsequent overrides win + if (m_baseScene >= 0) { + mixer->recallScene(m_baseScene); + } + + for (const auto& [path, value] : m_levels.asKeyValueRange()) { + mixer->sendParameter(path.toString(), value.toVariant()); + } + for (const auto& [path, value] : m_labels.asKeyValueRange()) { + mixer->sendParameter(path.toString(), value.toVariant()); + } +} + +QJsonObject CueZero::safeValues() const { + QJsonObject values = m_levels; + for (const auto& [path, value] : m_labels.asKeyValueRange()) { + values[path.toString()] = value; + } + return values; +} + +QJsonObject CueZero::toJson() const { + QJsonObject json; + json["baseScene"] = m_baseScene; + json["levels"] = m_levels; + json["labels"] = m_labels; + return json; +} + +void CueZero::loadFromJson(const QJsonObject& json) { + m_baseScene = json.contains("baseScene") ? json["baseScene"].toInt(-1) : -1; + m_levels = json["levels"].toObject(); + m_labels = json["labels"].toObject(); +} + +} // namespace OpenMix diff --git a/src/core/CueZero.h b/src/core/CueZero.h new file mode 100644 index 0000000..0b6b585 --- /dev/null +++ b/src/core/CueZero.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include +#include + +namespace OpenMix { + +class MixerProtocol; + +// Cue Zero (TheatreMix): the show's base / reset state. Recalling it returns the +// console to a known starting point before the first real cue -- an optional +// base scene plus a set of "level" parameters and "label" (name) parameters. +// Its parameters double as PlaybackGuard safe-values (what PANIC restores to). +class CueZero : public QObject { + Q_OBJECT + + public: + explicit CueZero(QObject* parent = nullptr); + + // optional base scene to recall first (-1 = none) + [[nodiscard]] int baseScene() const noexcept { return m_baseScene; } + void setBaseScene(int scene); + + // level parameters (OSC-ish path -> numeric value, e.g. "/ch/01/mix/fader" -> 0.5) + [[nodiscard]] QJsonObject levels() const { return m_levels; } + void setLevels(const QJsonObject& levels); + void setLevel(const QString& path, const QVariant& value); + + // label parameters (OSC-ish path -> string, e.g. "/ch/01/config/name" -> "Vocal") + [[nodiscard]] QJsonObject labels() const { return m_labels; } + void setLabels(const QJsonObject& labels); + void setLabel(const QString& path, const QString& name); + + [[nodiscard]] bool isEmpty() const; + void clear(); + + // push base scene + levels + labels to a connected mixer + void apply(MixerProtocol* mixer) const; + + // flattened levels + labels, suitable for PlaybackGuard::setDefaultSafeValues + [[nodiscard]] QJsonObject safeValues() const; + + QJsonObject toJson() const; + void loadFromJson(const QJsonObject& json); + + signals: + void changed(); + + private: + int m_baseScene = -1; + QJsonObject m_levels; + QJsonObject m_labels; +}; + +} // namespace OpenMix diff --git a/src/core/PlaybackEngine.cpp b/src/core/PlaybackEngine.cpp index 852fc9a..93ab764 100644 --- a/src/core/PlaybackEngine.cpp +++ b/src/core/PlaybackEngine.cpp @@ -4,6 +4,7 @@ #include "CueList.h" #include "DCAMapping.h" #include "PlaybackGuard.h" +#include "Position.h" #include "protocol/MixerCapabilities.h" #include "protocol/MixerProtocol.h" #include @@ -32,6 +33,8 @@ void PlaybackEngine::setDCAMapping(DCAMapping* mapping) { m_dcaMapping = mapping void PlaybackEngine::setActorLibrary(ActorProfileLibrary* library) { m_actorLibrary = library; } +void PlaybackEngine::setPositionLibrary(PositionLibrary* library) { m_positionLibrary = library; } + void PlaybackEngine::setValidator(CueValidator* validator) { m_validator = validator; } void PlaybackEngine::setGuard(PlaybackGuard* guard) { m_guard = guard; } @@ -223,6 +226,9 @@ void PlaybackEngine::executeCueInternal(const Cue& cue) { // apply per-channel fader levels (faded over cue.fadeTime if set) applyChannelLevels(cue); + // apply named-position pan/delay assignments + applyChannelPositions(cue); + // apply DCA overrides (mute & label only) applyDCAOverrides(cue, targetDCAs); @@ -323,6 +329,39 @@ void PlaybackEngine::applyChannelLevels(const Cue& cue) { } } +void PlaybackEngine::applyChannelPositions(const Cue& cue) { + if (!m_mixer || !m_positionLibrary) + return; + + // resolve each channel's assigned Position to pan + delay sends. Paths are the + // project's OSC-ish convention (matching cue parameters / LoopbackProtocol): + // /ch/NN/mix/pan channel image pan (-1..+1) + // /ch/NN/delay/on per-channel delay enable + // /ch/NN/delay/time per-channel delay (ms) + // /ch/NN/mix/BB/pan pan of the channel's send into bus BB (per-bus imaging) + const QMap channelPositions = cue.channelPositions(); + for (auto it = channelPositions.constBegin(); it != channelPositions.constEnd(); ++it) { + const int channel = it.key(); + std::optional position = m_positionLibrary->position(it.value()); + if (!position.has_value()) + continue; + + const QString ch = QString("%1").arg(channel, 2, 10, QChar('0')); + + m_mixer->sendParameter(QString("/ch/%1/mix/pan").arg(ch), position->pan()); + + if (position->delay() > 0.0) { + m_mixer->sendParameter(QString("/ch/%1/delay/on").arg(ch), 1); + m_mixer->sendParameter(QString("/ch/%1/delay/time").arg(ch), position->delay()); + } + + for (int bus : position->buses()) { + const QString busStr = QString("%1").arg(bus, 2, 10, QChar('0')); + m_mixer->sendParameter(QString("/ch/%1/mix/%2/pan").arg(ch, busStr), position->pan()); + } + } +} + void PlaybackEngine::applyVoice(int channel, const VoiceData& voice) { if (voice.gainDb.has_value()) m_mixer->setChannelPreamp(channel, *voice.gainDb); diff --git a/src/core/PlaybackEngine.h b/src/core/PlaybackEngine.h index 0c84b51..abe73e0 100644 --- a/src/core/PlaybackEngine.h +++ b/src/core/PlaybackEngine.h @@ -17,6 +17,7 @@ class MixerProtocol; class PlaybackGuard; class PlaybackLogger; class ActorProfileLibrary; +class PositionLibrary; struct VoiceData; enum class PlaybackState { Stopped, Running }; @@ -31,6 +32,7 @@ class PlaybackEngine : public QObject { void setMixer(MixerProtocol* mixer); void setDCAMapping(DCAMapping* mapping); void setActorLibrary(ActorProfileLibrary* library); + void setPositionLibrary(PositionLibrary* library); // drives timed fader fades; exposed so the UI (and tests) can observe/step it [[nodiscard]] FadeEngine* fadeEngine() { return &m_fadeEngine; } @@ -117,12 +119,14 @@ class PlaybackEngine : public QObject { void expandChannelProfiles(const Cue& cue); void applyVoice(int channel, const VoiceData& voice); void applyChannelLevels(const Cue& cue); // fade-aware per-channel fader moves + void applyChannelPositions(const Cue& cue); // named-position pan/delay sends void verifyCue(int index, const Cue& cue); CueList* m_cueList = nullptr; MixerProtocol* m_mixer = nullptr; DCAMapping* m_dcaMapping = nullptr; ActorProfileLibrary* m_actorLibrary = nullptr; + PositionLibrary* m_positionLibrary = nullptr; FadeEngine m_fadeEngine; QMap m_appliedChannelLevels; // last-applied fader per channel (fade source) diff --git a/src/core/Position.cpp b/src/core/Position.cpp new file mode 100644 index 0000000..49e5751 --- /dev/null +++ b/src/core/Position.cpp @@ -0,0 +1,136 @@ +#include "Position.h" + +namespace OpenMix { + +Position::Position() : m_id(QUuid::createUuid().toString(QUuid::WithoutBraces)) {} + +Position::Position(const QString& name, const QString& shortName) + : m_id(QUuid::createUuid().toString(QUuid::WithoutBraces)), m_name(name), + m_shortName(shortName) {} + +void Position::regenerateId() { m_id = QUuid::createUuid().toString(QUuid::WithoutBraces); } + +QJsonObject Position::toJson() const { + QJsonObject json; + json["id"] = m_id; + json["name"] = m_name; + json["shortName"] = m_shortName; + json["delay"] = m_delay; + json["pan"] = m_pan; + + if (!m_buses.isEmpty()) { + QJsonArray busArray; + for (int bus : m_buses) { + busArray.append(bus); + } + json["buses"] = busArray; + } + return json; +} + +Position Position::fromJson(const QJsonObject& json) { + Position position; + position.m_id = json["id"].toString(); + if (position.m_id.isEmpty()) { + position.m_id = QUuid::createUuid().toString(QUuid::WithoutBraces); + } + position.m_name = json["name"].toString(); + position.m_shortName = json["shortName"].toString(); + position.m_delay = json["delay"].toDouble(); + position.m_pan = json["pan"].toDouble(); + + if (json.contains("buses")) { + for (const QJsonValue& val : json["buses"].toArray()) { + position.m_buses.append(val.toInt()); + } + } + return position; +} + +// --- PositionLibrary --- + +PositionLibrary::PositionLibrary(QObject* parent) : QObject(parent) {} + +int PositionLibrary::indexOf(const QString& id) const { + for (int i = 0; i < m_positions.size(); ++i) { + if (m_positions[i].id() == id) { + return i; + } + } + return -1; +} + +bool PositionLibrary::contains(const QString& id) const { return indexOf(id) >= 0; } + +QString PositionLibrary::addPosition(const Position& position) { + m_positions.append(position); + emit positionAdded(position.id()); + emit changed(); + return position.id(); +} + +void PositionLibrary::updatePosition(const Position& position) { + int idx = indexOf(position.id()); + if (idx >= 0) { + m_positions[idx] = position; + emit positionChanged(position.id()); + emit changed(); + } else { + addPosition(position); + } +} + +bool PositionLibrary::removePosition(const QString& id) { + int idx = indexOf(id); + if (idx < 0) { + return false; + } + m_positions.removeAt(idx); + emit positionRemoved(id); + emit changed(); + return true; +} + +void PositionLibrary::setPositions(const QList& positions) { + m_positions = positions; + emit changed(); +} + +void PositionLibrary::clear() { + m_positions.clear(); + emit libraryCleared(); + emit changed(); +} + +std::optional PositionLibrary::position(const QString& id) const { + int idx = indexOf(id); + if (idx < 0) { + return std::nullopt; + } + return m_positions[idx]; +} + +QJsonObject PositionLibrary::toJson() const { + QJsonObject json; + QJsonArray arr; + for (const Position& position : m_positions) { + arr.append(position.toJson()); + } + json["positions"] = arr; + return json; +} + +PositionLibrary* PositionLibrary::fromJson(const QJsonObject& json, QObject* parent) { + auto* library = new PositionLibrary(parent); + library->loadFromJson(json); + return library; +} + +void PositionLibrary::loadFromJson(const QJsonObject& json) { + m_positions.clear(); + for (const QJsonValue& val : json["positions"].toArray()) { + m_positions.append(Position::fromJson(val.toObject())); + } +} + +} // namespace OpenMix diff --git a/src/core/Position.h b/src/core/Position.h new file mode 100644 index 0000000..a1929bd --- /dev/null +++ b/src/core/Position.h @@ -0,0 +1,96 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace OpenMix { + +// A named spatial position (TheatreMix "positions"): a reusable pan/delay +// preset that a cue can assign to one or more input channels so an actor's +// voice images to a consistent place in the room. +class Position { + public: + Position(); + explicit Position(const QString& name, const QString& shortName = QString()); + + [[nodiscard]] QString id() const { return m_id; } + void regenerateId(); + + [[nodiscard]] QString name() const { return m_name; } + void setName(const QString& name) { m_name = name; } + + [[nodiscard]] QString shortName() const { return m_shortName; } + void setShortName(const QString& shortName) { m_shortName = shortName; } + + // delay in milliseconds (image-shifting / time alignment) + [[nodiscard]] double delay() const noexcept { return m_delay; } + void setDelay(double ms) { m_delay = ms; } + + // pan, normalized -1.0 (full left) .. 0.0 (centre) .. +1.0 (full right) + [[nodiscard]] double pan() const noexcept { return m_pan; } + void setPan(double pan) { m_pan = pan; } + + // buses this position images to (e.g. surround/LCR sends); empty = main only + [[nodiscard]] QList buses() const { return m_buses; } + void setBuses(const QList& buses) { m_buses = buses; } + + QJsonObject toJson() const; + [[nodiscard]] static Position fromJson(const QJsonObject& json); + + bool operator==(const Position& other) const noexcept { return m_id == other.m_id; } + + private: + QString m_id; + QString m_name; + QString m_shortName; + double m_delay = 0.0; + double m_pan = 0.0; + QList m_buses; +}; + +// Show-owned library of named positions. Mirrors DCAMapping: holds the data, +// emits change signals, and serializes via toJson/loadFromJson. +class PositionLibrary : public QObject { + Q_OBJECT + + public: + explicit PositionLibrary(QObject* parent = nullptr); + + // mutation (each emits + returns the position id) + QString addPosition(const Position& position); + void updatePosition(const Position& position); + bool removePosition(const QString& id); + void setPositions(const QList& positions); + void clear(); + + // query + [[nodiscard]] std::optional position(const QString& id) const; + [[nodiscard]] QList positions() const { return m_positions; } + [[nodiscard]] int count() const { return m_positions.size(); } + [[nodiscard]] bool isEmpty() const { return m_positions.isEmpty(); } + [[nodiscard]] bool contains(const QString& id) const; + + // serialization + QJsonObject toJson() const; + [[nodiscard]] static PositionLibrary* fromJson(const QJsonObject& json, QObject* parent = nullptr); + void loadFromJson(const QJsonObject& json); + + signals: + void changed(); // any mutation (Show connects this to its dirty flag) + void positionAdded(const QString& id); + void positionRemoved(const QString& id); + void positionChanged(const QString& id); + void libraryCleared(); + + private: + [[nodiscard]] int indexOf(const QString& id) const; + + QList m_positions; +}; + +} // namespace OpenMix diff --git a/src/core/Show.cpp b/src/core/Show.cpp index 44927fc..72c58ef 100644 --- a/src/core/Show.cpp +++ b/src/core/Show.cpp @@ -21,10 +21,13 @@ MixerConfig MixerConfig::fromJson(const QJsonObject& json) { } Show::Show(QObject* parent) - : QObject(parent), m_cueList(this), m_dcaMapping(this), m_actorProfileLibrary(this) { + : QObject(parent), m_cueList(this), m_dcaMapping(this), m_actorProfileLibrary(this), + m_positionLibrary(this), m_cueZero(this) { connectCueListSignals(); connectDcaMappingSignals(); connectActorLibrarySignals(); + connectPositionLibrarySignals(); + connectCueZeroSignals(); newShow(); } @@ -69,6 +72,14 @@ void Show::connectActorLibrarySignals() { connect(&m_actorProfileLibrary, &ActorProfileLibrary::changed, this, &Show::checkModifiedState); } +void Show::connectPositionLibrarySignals() { + connect(&m_positionLibrary, &PositionLibrary::changed, this, &Show::checkModifiedState); +} + +void Show::connectCueZeroSignals() { + connect(&m_cueZero, &CueZero::changed, this, &Show::checkModifiedState); +} + void Show::newShow() { m_name = tr("Untitled Show"); m_author.clear(); @@ -80,6 +91,8 @@ void Show::newShow() { m_cueList.clear(); m_dcaMapping.clear(); m_actorProfileLibrary.clear(); + m_positionLibrary.clear(); + m_cueZero.clear(); m_isDirty = false; } @@ -93,6 +106,8 @@ QJsonObject Show::toJson() const { json["cues"] = m_cueList.toJson(); json["dcaMapping"] = m_dcaMapping.toJson(); json["actors"] = m_actorProfileLibrary.toJson(); + json["positions"] = m_positionLibrary.toJson(); + json["cueZero"] = m_cueZero.toJson(); return json; } @@ -121,6 +136,20 @@ void Show::fromJson(const QJsonObject& json) { m_actorProfileLibrary.clear(); } + // named-position library (added in show version 1.2) + if (json.contains("positions")) { + m_positionLibrary.loadFromJson(json["positions"].toObject()); + } else { + m_positionLibrary.clear(); + } + + // Cue Zero base state (added in show version 1.2) + if (json.contains("cueZero")) { + m_cueZero.loadFromJson(json["cueZero"].toObject()); + } else { + m_cueZero.clear(); + } + m_isDirty = false; emit nameChanged(m_name); } diff --git a/src/core/Show.h b/src/core/Show.h index d586b0b..885ec07 100644 --- a/src/core/Show.h +++ b/src/core/Show.h @@ -2,13 +2,17 @@ #include "ActorProfileLibrary.h" #include "CueList.h" +#include "CueZero.h" #include "DCAMapping.h" +#include "Position.h" #include #include #include namespace OpenMix { +class MixerProtocol; + struct MixerConfig { static constexpr int DEFAULT_PORT = 10023; static constexpr int DEFAULT_DCA_COUNT = 8; @@ -55,6 +59,15 @@ class Show : public QObject { return &m_actorProfileLibrary; } + [[nodiscard]] PositionLibrary* positionLibrary() { return &m_positionLibrary; } + [[nodiscard]] const PositionLibrary* positionLibrary() const { return &m_positionLibrary; } + + [[nodiscard]] CueZero* cueZero() { return &m_cueZero; } + [[nodiscard]] const CueZero* cueZero() const { return &m_cueZero; } + + // recall the show's Cue Zero base state onto a connected mixer + void applyCueZero(MixerProtocol* mixer) const { m_cueZero.apply(mixer); } + [[nodiscard]] MixerConfig mixerConfig() const { return m_mixerConfig; } void setMixerConfig(const MixerConfig& config) { m_mixerConfig = config; checkModifiedState(); } @@ -71,6 +84,8 @@ class Show : public QObject { void connectCueListSignals(); void connectDcaMappingSignals(); void connectActorLibrarySignals(); + void connectPositionLibrarySignals(); + void connectCueZeroSignals(); QString m_name; QString m_author; @@ -80,6 +95,8 @@ class Show : public QObject { MixerConfig m_mixerConfig; DCAMapping m_dcaMapping; ActorProfileLibrary m_actorProfileLibrary; + PositionLibrary m_positionLibrary; + CueZero m_cueZero; bool m_isDirty = false; }; diff --git a/src/core/TimecodeTrigger.cpp b/src/core/TimecodeTrigger.cpp new file mode 100644 index 0000000..ab01595 --- /dev/null +++ b/src/core/TimecodeTrigger.cpp @@ -0,0 +1,134 @@ +#include "TimecodeTrigger.h" +#include +#include + +namespace OpenMix { + +QJsonObject TimecodeTrigger::toJson() const { + QJsonObject json; + json["id"] = id; + json["hours"] = hours; + json["minutes"] = minutes; + json["seconds"] = seconds; + json["frames"] = frames; + json["cueNumber"] = cueNumber; + json["enabled"] = enabled; + return json; +} + +TimecodeTrigger TimecodeTrigger::fromJson(const QJsonObject& json) { + TimecodeTrigger t; + t.id = json["id"].toString(); + if (t.id.isEmpty()) { + t.id = QUuid::createUuid().toString(QUuid::WithoutBraces); + } + t.hours = json["hours"].toInt(); + t.minutes = json["minutes"].toInt(); + t.seconds = json["seconds"].toInt(); + t.frames = json["frames"].toInt(); + t.cueNumber = json["cueNumber"].toDouble(); + t.enabled = json["enabled"].toBool(true); + return t; +} + +// --- TimecodeTriggerList --- + +TimecodeTriggerList::TimecodeTriggerList(QObject* parent) : QObject(parent) {} + +QString TimecodeTriggerList::addTrigger(int h, int m, int s, int f, double cueNumber) { + TimecodeTrigger trigger; + trigger.id = QUuid::createUuid().toString(QUuid::WithoutBraces); + trigger.hours = h; + trigger.minutes = m; + trigger.seconds = s; + trigger.frames = f; + trigger.cueNumber = cueNumber; + addTrigger(trigger); + return trigger.id; +} + +void TimecodeTriggerList::addTrigger(const TimecodeTrigger& trigger) { + m_triggers.append(trigger); + emit triggerAdded(trigger.id); +} + +bool TimecodeTriggerList::removeTrigger(const QString& id) { + for (int i = 0; i < m_triggers.size(); ++i) { + if (m_triggers[i].id == id) { + m_triggers.removeAt(i); + m_fired.remove(id); + emit triggerRemoved(id); + return true; + } + } + return false; +} + +void TimecodeTriggerList::setTriggerEnabled(const QString& id, bool enabled) { + for (TimecodeTrigger& trigger : m_triggers) { + if (trigger.id == id) { + trigger.enabled = enabled; + return; + } + } +} + +void TimecodeTriggerList::setTriggers(const QList& triggers) { + m_triggers = triggers; + m_fired.clear(); +} + +void TimecodeTriggerList::clear() { + m_triggers.clear(); + m_fired.clear(); + m_lastOrder = -1; + emit triggersCleared(); +} + +void TimecodeTriggerList::resetArming() { + m_fired.clear(); + m_lastOrder = -1; +} + +void TimecodeTriggerList::onTimecode(int hours, int minutes, int seconds, int frames) { + const long current = order(hours, minutes, seconds, frames); + + for (const TimecodeTrigger& trigger : m_triggers) { + if (!trigger.enabled) { + continue; + } + const long target = order(trigger.hours, trigger.minutes, trigger.seconds, trigger.frames); + + if (current < target) { + // timecode is before the trigger point: (re-)arm it + m_fired.remove(trigger.id); + } else if (!m_fired.contains(trigger.id) && m_lastOrder < target) { + // crossed from before the point to at/after it: fire once + m_fired.insert(trigger.id); + emit triggerFired(trigger.cueNumber, trigger.id); + } + } + + m_lastOrder = current; +} + +QJsonObject TimecodeTriggerList::toJson() const { + QJsonObject json; + QJsonArray arr; + for (const TimecodeTrigger& trigger : m_triggers) { + arr.append(trigger.toJson()); + } + json["triggers"] = arr; + return json; +} + +void TimecodeTriggerList::loadFromJson(const QJsonObject& json) { + m_triggers.clear(); + m_fired.clear(); + m_lastOrder = -1; + for (const QJsonValue& val : json["triggers"].toArray()) { + m_triggers.append(TimecodeTrigger::fromJson(val.toObject())); + } +} + +} // namespace OpenMix diff --git a/src/core/TimecodeTrigger.h b/src/core/TimecodeTrigger.h new file mode 100644 index 0000000..28c1551 --- /dev/null +++ b/src/core/TimecodeTrigger.h @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace OpenMix { + +// One timecode -> cue association: when playback timecode reaches hh:mm:ss:ff, +// fire the cue with this number. +struct TimecodeTrigger { + QString id; + int hours = 0; + int minutes = 0; + int seconds = 0; + int frames = 0; + double cueNumber = 0.0; + bool enabled = true; + + QJsonObject toJson() const; + [[nodiscard]] static TimecodeTrigger fromJson(const QJsonObject& json); +}; + +// Registry of timecode triggers. Fed incoming timecode via onTimecode() (wire it +// to MidiInputManager::timecodeChanged); emits triggerFired() with the target cue +// number as timecode crosses each trigger's point, so the orchestrator can route +// it to PlaybackEngine (goToNumber + go). TheatreMix's "timecode" cue list. +class TimecodeTriggerList : public QObject { + Q_OBJECT + + public: + explicit TimecodeTriggerList(QObject* parent = nullptr); + + QString addTrigger(int h, int m, int s, int f, double cueNumber); + void addTrigger(const TimecodeTrigger& trigger); + bool removeTrigger(const QString& id); + void setTriggerEnabled(const QString& id, bool enabled); + void setTriggers(const QList& triggers); + void clear(); + + [[nodiscard]] QList triggers() const { return m_triggers; } + [[nodiscard]] int count() const { return m_triggers.size(); } + [[nodiscard]] bool isEmpty() const { return m_triggers.isEmpty(); } + + QJsonObject toJson() const; + void loadFromJson(const QJsonObject& json); + + public slots: + // feed current playback timecode; fires any trigger whose point is crossed + void onTimecode(int hours, int minutes, int seconds, int frames); + // re-arm all triggers (e.g. when transport stops / relocates) + void resetArming(); + + signals: + void triggerFired(double cueNumber, const QString& triggerId); + void triggerAdded(const QString& id); + void triggerRemoved(const QString& id); + void triggersCleared(); + + private: + // monotonic ordering key; FPS_BASE comfortably exceeds any real frame count so + // the key orders correctly across 24/25/29.97/30 fps without knowing the rate. + static constexpr long FPS_BASE = 100; + static long order(int h, int m, int s, int f) { + return ((static_cast(h) * 60 + m) * 60 + s) * FPS_BASE + f; + } + + QList m_triggers; + QSet m_fired; // triggers already fired since last crossing/re-arm + long m_lastOrder = -1; +}; + +} // namespace OpenMix diff --git a/src/midi/MidiInputManager.cpp b/src/midi/MidiInputManager.cpp index 7c30b7a..332e6a8 100644 --- a/src/midi/MidiInputManager.cpp +++ b/src/midi/MidiInputManager.cpp @@ -62,8 +62,10 @@ bool MidiInputManager::openDevice(int deviceIndex) { try { m_midiIn->openPort(deviceIndex); m_midiIn->setCallback(&MidiInputManager::midiCallback, this); - // receive sysex (for MIDI Show Control); still ignore timing + active sensing - m_midiIn->ignoreTypes(false, true, true); + // receive sysex (MIDI Show Control) and timing (MTC quarter-frame, 0xF1); + // still ignore active sensing. Single-byte clock (0xF8) is dropped by the + // size check in processMidiMessage. + m_midiIn->ignoreTypes(false, false, true); m_currentDeviceName = QString::fromStdString(m_midiIn->getPortName(deviceIndex)); m_savedDeviceName = m_currentDeviceName; @@ -248,6 +250,16 @@ void MidiInputManager::processMidiMessage(const std::vector& mess return; } + // MIDI Time Code quarter-frame (0xF1 + data nibble): assemble the running + // timecode; emit once a full hh:mm:ss:ff frame completes (every 8th message) + if (message[0] == 0xF1) { + if (m_mtcParser.parseQuarterFrame(message[1])) { + const MtcTime tc = m_mtcParser.time(); + emit timecodeChanged(tc.hours, tc.minutes, tc.seconds, tc.frames); + } + return; + } + unsigned char status = message[0]; int channel = status & 0x0F; int statusType = status & 0xF0; diff --git a/src/midi/MidiInputManager.h b/src/midi/MidiInputManager.h index b01ee09..21ecb75 100644 --- a/src/midi/MidiInputManager.h +++ b/src/midi/MidiInputManager.h @@ -1,6 +1,7 @@ #pragma once #include "MidiControlMapping.h" +#include "MtcParser.h" #include #include @@ -77,6 +78,8 @@ class MidiInputManager : public QObject { void midiLearnReceived(const MidiTrigger& trigger); void actionExecuted(MidiAction action); void mscReceived(int command); // a MIDI Show Control command was handled + // a full MIDI Time Code frame was assembled (from quarter-frame or full-frame) + void timecodeChanged(int hours, int minutes, int seconds, int frames); private slots: void onDevicePollTimer(); @@ -108,6 +111,8 @@ class MidiInputManager : public QObject { QTimer m_devicePollTimer; QStringList m_lastDeviceList; + MtcParser m_mtcParser; // assembles MIDI Time Code quarter-frames + PlaybackEngine* m_engine = nullptr; PlaybackGuard* m_guard = nullptr; }; diff --git a/src/midi/MtcParser.h b/src/midi/MtcParser.h new file mode 100644 index 0000000..392862d --- /dev/null +++ b/src/midi/MtcParser.h @@ -0,0 +1,93 @@ +#pragma once + +#include +#include +#include + +namespace OpenMix { + +// MIDI Time Code frame rate, encoded in the high bits of the hours field. +enum class MtcRate { + Fps24 = 0, // 24 fps (film) + Fps25 = 1, // 25 fps (EBU) + Fps2997 = 2, // 29.97 fps drop-frame + Fps30 = 3 // 30 fps +}; + +struct MtcTime { + int hours = 0; + int minutes = 0; + int seconds = 0; + int frames = 0; + MtcRate rate = MtcRate::Fps30; + bool valid = false; +}; + +// Assembles MIDI Time Code from the two transports the standard defines: +// +// * Quarter-frame messages: status 0xF1 followed by one data byte. The data +// byte is (pieceIndex << 4) | nibble, pieces 0..7. A full hh:mm:ss:ff is +// spread across all 8 pieces; parseQuarterFrame() returns true once piece 7 +// completes a frame. +// * Full-frame: a SysEx F0 7F 01 01 hh mm ss ff F7 carrying a whole +// timecode in one message (used on locate/seek). +// +// Header-only and dependency-free (mirrors MscParser.h) so it is trivial to +// unit-test the bit assembly in isolation. +class MtcParser { + public: + // Feed one quarter-frame DATA byte (the byte after the 0xF1 status). + // Returns true when this byte was piece 7 and a full frame is now assembled. + bool parseQuarterFrame(unsigned char dataByte) { + const int piece = (dataByte >> 4) & 0x07; + const int nibble = dataByte & 0x0F; + m_nibbles[piece] = static_cast(nibble); + + if (piece == 7) { + assemble(); + return true; + } + return false; + } + + // Parse a MTC full-frame SysEx. Returns a valid MtcTime on success. + static MtcTime parseFullFrame(const std::vector& m) { + MtcTime t; + if (m.size() < 10 || m[0] != 0xF0 || m[1] != 0x7F || m[3] != 0x01 || m[4] != 0x01 || + m.back() != 0xF7) { + return t; + } + const unsigned char rateHour = m[5]; + t.hours = rateHour & 0x1F; + t.rate = static_cast((rateHour >> 5) & 0x03); + t.minutes = m[6] & 0x3F; + t.seconds = m[7] & 0x3F; + t.frames = m[8] & 0x1F; + t.valid = true; + return t; + } + + [[nodiscard]] MtcTime time() const { return m_time; } + + void reset() { + m_nibbles.fill(0); + m_time = MtcTime{}; + } + + private: + void assemble() { + m_time.frames = (m_nibbles[0] & 0x0F) | ((m_nibbles[1] & 0x01) << 4); + m_time.seconds = (m_nibbles[2] & 0x0F) | ((m_nibbles[3] & 0x03) << 4); + m_time.minutes = (m_nibbles[4] & 0x0F) | ((m_nibbles[5] & 0x03) << 4); + const int hourLow = m_nibbles[6] & 0x0F; + const int hourHighAndRate = m_nibbles[7] & 0x0F; + m_time.hours = hourLow | ((hourHighAndRate & 0x01) << 4); + m_time.rate = static_cast((hourHighAndRate >> 1) & 0x03); + m_time.valid = true; + } + + std::array m_nibbles{}; + MtcTime m_time; +}; + +} // namespace OpenMix diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6ce4a05..f045520 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -36,6 +36,8 @@ add_executable(test_show ${CMAKE_SOURCE_DIR}/src/core/ActorProfile.cpp ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp ${CMAKE_SOURCE_DIR}/src/core/ActorProfileLibrary.cpp + ${CMAKE_SOURCE_DIR}/src/core/Position.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueZero.cpp ) target_link_libraries(test_show PRIVATE Qt6::Core @@ -55,6 +57,8 @@ add_executable(test_actor_profiles ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp ${CMAKE_SOURCE_DIR}/src/core/Show.cpp + ${CMAKE_SOURCE_DIR}/src/core/Position.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueZero.cpp ) target_link_libraries(test_actor_profiles PRIVATE Qt6::Core @@ -78,6 +82,7 @@ add_executable(test_playback_profiles ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp ${CMAKE_SOURCE_DIR}/src/core/ActorProfileLibrary.cpp ${CMAKE_SOURCE_DIR}/src/core/FadeEngine.cpp + ${CMAKE_SOURCE_DIR}/src/core/Position.cpp ${CMAKE_SOURCE_DIR}/src/protocol/LoopbackProtocol.cpp ${CMAKE_SOURCE_DIR}/src/protocol/MixerProtocol.cpp ${CMAKE_SOURCE_DIR}/src/protocol/MixerCapabilities.cpp @@ -123,6 +128,7 @@ add_executable(test_osc_remote ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp ${CMAKE_SOURCE_DIR}/src/core/ActorProfileLibrary.cpp ${CMAKE_SOURCE_DIR}/src/core/FadeEngine.cpp + ${CMAKE_SOURCE_DIR}/src/core/Position.cpp ${CMAKE_SOURCE_DIR}/src/protocol/MixerProtocol.cpp ${CMAKE_SOURCE_DIR}/src/protocol/MixerCapabilities.cpp ) @@ -205,3 +211,47 @@ if(WIN32) target_compile_definitions(test_yamaha_scp PRIVATE NOMINMAX) endif() add_test(NAME YamahaScpTest COMMAND test_yamaha_scp) + +# --- Phase 5 backend: positions, cue zero, timecode, channel monitoring --- + +add_executable(test_position + test_position.cpp + ${CMAKE_SOURCE_DIR}/src/core/Position.cpp + ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp +) +target_link_libraries(test_position PRIVATE Qt6::Core Qt6::Test) +target_include_directories(test_position PRIVATE ${CMAKE_SOURCE_DIR}/src) +add_test(NAME PositionTest COMMAND test_position) + +add_executable(test_mtc test_mtc.cpp) +target_link_libraries(test_mtc PRIVATE Qt6::Core Qt6::Test) +target_include_directories(test_mtc PRIVATE ${CMAKE_SOURCE_DIR}/src) +add_test(NAME MtcTest COMMAND test_mtc) + +add_executable(test_timecode_trigger + test_timecode_trigger.cpp + ${CMAKE_SOURCE_DIR}/src/core/TimecodeTrigger.cpp +) +target_link_libraries(test_timecode_trigger PRIVATE Qt6::Core Qt6::Test) +target_include_directories(test_timecode_trigger PRIVATE ${CMAKE_SOURCE_DIR}/src) +add_test(NAME TimecodeTriggerTest COMMAND test_timecode_trigger) + +add_executable(test_channel_monitor + test_channel_monitor.cpp + ${CMAKE_SOURCE_DIR}/src/core/ChannelMonitor.cpp +) +target_link_libraries(test_channel_monitor PRIVATE Qt6::Core Qt6::Test) +target_include_directories(test_channel_monitor PRIVATE ${CMAKE_SOURCE_DIR}/src) +add_test(NAME ChannelMonitorTest COMMAND test_channel_monitor) + +add_executable(test_cuezero + test_cuezero.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueZero.cpp + ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/LoopbackProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/MixerProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/MixerCapabilities.cpp +) +target_link_libraries(test_cuezero PRIVATE Qt6::Core Qt6::Test) +target_include_directories(test_cuezero PRIVATE ${CMAKE_SOURCE_DIR}/src) +add_test(NAME CueZeroTest COMMAND test_cuezero) diff --git a/tests/test_channel_monitor.cpp b/tests/test_channel_monitor.cpp new file mode 100644 index 0000000..705e492 --- /dev/null +++ b/tests/test_channel_monitor.cpp @@ -0,0 +1,92 @@ +#include "core/ChannelMonitor.h" +#include +#include + +using namespace OpenMix; + +class TestChannelMonitor : public QObject { + Q_OBJECT + + static constexpr int kSilenceMs = 40; + static constexpr int kClipMs = 40; + + static void configure(ChannelMonitor& monitor) { + monitor.setSilenceThreshold(0.05); + monitor.setClipThreshold(0.95); + monitor.setSilenceTimeoutMs(kSilenceMs); + monitor.setClipHoldMs(kClipMs); + } + + private slots: + void normalLevel_staysNormal() { + ChannelMonitor monitor; + configure(monitor); + QSignalSpy spy(&monitor, &ChannelMonitor::channelStateChanged); + + monitor.onLevel(1, 0.5); + QCOMPARE(monitor.channelState(1), ChannelState::Normal); + QCOMPARE(spy.count(), 0); // already Normal, no transition + } + + void silence_afterTimeout() { + ChannelMonitor monitor; + configure(monitor); + QSignalSpy spy(&monitor, &ChannelMonitor::channelStateChanged); + + monitor.onLevel(1, 0.0); + // not silent yet: timeout has not elapsed + QCOMPARE(monitor.channelState(1), ChannelState::Normal); + QCOMPARE(spy.count(), 0); + + QVERIFY(QTest::qWaitFor( + [&]() { return monitor.channelState(1) == ChannelState::Silent; }, 4 * kSilenceMs)); + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(0).toInt(), 1); // channel + QCOMPARE(spy.at(0).at(1).toInt(), int(ChannelState::Silent)); + } + + void silence_recoversWhenAudioReturns() { + ChannelMonitor monitor; + configure(monitor); + monitor.onLevel(1, 0.0); + QVERIFY(QTest::qWaitFor( + [&]() { return monitor.channelState(1) == ChannelState::Silent; }, 4 * kSilenceMs)); + + QSignalSpy spy(&monitor, &ChannelMonitor::channelStateChanged); + monitor.onLevel(1, 0.5); // audio back + QCOMPARE(monitor.channelState(1), ChannelState::Normal); + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(1).toInt(), int(ChannelState::Normal)); + } + + void clip_thenRestores() { + ChannelMonitor monitor; + configure(monitor); + QSignalSpy spy(&monitor, &ChannelMonitor::channelStateChanged); + + monitor.onLevel(2, 1.0); + // clipping is immediate + QCOMPARE(monitor.channelState(2), ChannelState::Clipping); + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(1).toInt(), int(ChannelState::Clipping)); + + // after the hold window with no further clips, restore to Normal + QVERIFY(QTest::qWaitFor( + [&]() { return monitor.channelState(2) == ChannelState::Normal; }, 4 * kClipMs)); + QCOMPARE(spy.count(), 2); + QCOMPARE(spy.at(1).at(1).toInt(), int(ChannelState::Normal)); + } + + void clip_holdsWhileHot() { + ChannelMonitor monitor; + configure(monitor); + monitor.onLevel(3, 1.0); + QCOMPARE(monitor.channelState(3), ChannelState::Clipping); + // a quiet sample during the hold window must not flip it to Silent/Normal + monitor.onLevel(3, 0.0); + QCOMPARE(monitor.channelState(3), ChannelState::Clipping); + } +}; + +QTEST_MAIN(TestChannelMonitor) +#include "test_channel_monitor.moc" diff --git a/tests/test_cuezero.cpp b/tests/test_cuezero.cpp new file mode 100644 index 0000000..85af8d5 --- /dev/null +++ b/tests/test_cuezero.cpp @@ -0,0 +1,89 @@ +#include "core/CueZero.h" +#include "protocol/LoopbackProtocol.h" +#include "protocol/MixerCapabilities.h" +#include +#include + +using namespace OpenMix; + +class TestCueZero : public QObject { + Q_OBJECT + + private slots: + void apply_pushesSceneLevelsAndLabels() { + LoopbackProtocol mixer(MixerCapabilities::forConsole(ConsoleType::Loopback)); + mixer.connect("", 0); // loopback connects synchronously + QVERIFY(mixer.isConnected()); + + CueZero cz; + cz.setBaseScene(5); + cz.setLevel("/ch/01/mix/fader", 0.5); + cz.setLevel("/ch/02/mix/fader", 0.25); + cz.setLabel("/ch/01/config/name", "Lead Vox"); + + QSignalSpy sceneSpy(&mixer, &MixerProtocol::sceneChanged); + cz.apply(&mixer); + + // base scene recalled + QCOMPARE(sceneSpy.count(), 1); + QCOMPARE(sceneSpy.at(0).at(0).toInt(), 5); + + // levels + labels landed on the mixer (loopback records sends) + QCOMPARE(mixer.getParameter("/ch/01/mix/fader").toDouble(), 0.5); + QCOMPARE(mixer.getParameter("/ch/02/mix/fader").toDouble(), 0.25); + QCOMPARE(mixer.getParameter("/ch/01/config/name").toString(), QString("Lead Vox")); + } + + void apply_noSceneWhenBaseSceneUnset() { + LoopbackProtocol mixer(MixerCapabilities::forConsole(ConsoleType::Loopback)); + mixer.connect("", 0); + + CueZero cz; + cz.setLevel("/ch/03/mix/fader", 0.8); + + QSignalSpy sceneSpy(&mixer, &MixerProtocol::sceneChanged); + cz.apply(&mixer); + + QCOMPARE(sceneSpy.count(), 0); // baseScene == -1, no recall + QCOMPARE(mixer.getParameter("/ch/03/mix/fader").toDouble(), 0.8); + } + + void safeValues_mergesLevelsAndLabels() { + // Cue Zero doubles as PlaybackGuard safe-values (what PANIC restores to) + CueZero cz; + cz.setLevel("/ch/01/mix/fader", 0.0); + cz.setLabel("/ch/01/config/name", "Reset"); + + const QJsonObject safe = cz.safeValues(); + QCOMPARE(safe.value("/ch/01/mix/fader").toDouble(), 0.0); + QCOMPARE(safe.value("/ch/01/config/name").toString(), QString("Reset")); + QCOMPARE(safe.size(), 2); + } + + void serialization_roundTrip() { + CueZero cz; + cz.setBaseScene(7); + cz.setLevel("/main/st/mix/fader", 0.75); + cz.setLabel("/dca/1/config/name", "Band"); + + CueZero restored; + restored.loadFromJson(cz.toJson()); + + QCOMPARE(restored.baseScene(), 7); + QCOMPARE(restored.levels().value("/main/st/mix/fader").toDouble(), 0.75); + QCOMPARE(restored.labels().value("/dca/1/config/name").toString(), QString("Band")); + } + + void clear_resetsEverything() { + CueZero cz; + cz.setBaseScene(3); + cz.setLevel("/x", 1.0); + QVERIFY(!cz.isEmpty()); + cz.clear(); + QVERIFY(cz.isEmpty()); + QCOMPARE(cz.baseScene(), -1); + } +}; + +QTEST_MAIN(TestCueZero) +#include "test_cuezero.moc" diff --git a/tests/test_mtc.cpp b/tests/test_mtc.cpp new file mode 100644 index 0000000..3fd4ca9 --- /dev/null +++ b/tests/test_mtc.cpp @@ -0,0 +1,88 @@ +#include "midi/MtcParser.h" +#include +#include + +using namespace OpenMix; + +class TestMtc : public QObject { + Q_OBJECT + + private: + // build the 8 quarter-frame data bytes for a timecode, then feed them. + static MtcTime feedQuarterFrames(MtcParser& parser, int h, int m, int s, int f, int rate) { + const int hourField = (h & 0x1F) | ((rate & 0x03) << 5); + const std::array nibbles = { + f & 0x0F, (f >> 4) & 0x0F, s & 0x0F, (s >> 4) & 0x0F, + m & 0x0F, (m >> 4) & 0x0F, hourField & 0x0F, + (hourField >> 4) & 0x0F}; + + bool completed = false; + for (int piece = 0; piece < 8; ++piece) { + const unsigned char dataByte = static_cast((piece << 4) | nibbles[piece]); + completed = parser.parseQuarterFrame(dataByte); + } + Q_ASSERT(completed); + return parser.time(); + } + + private slots: + void quarterFrame_assemblesSmallTimecode() { + MtcParser parser; + const MtcTime tc = feedQuarterFrames(parser, 1, 2, 3, 4, /*25fps*/ 1); + QVERIFY(tc.valid); + QCOMPARE(tc.hours, 1); + QCOMPARE(tc.minutes, 2); + QCOMPARE(tc.seconds, 3); + QCOMPARE(tc.frames, 4); + QCOMPARE(static_cast(tc.rate), 1); // Fps25 + } + + void quarterFrame_assemblesMaxFields() { + MtcParser parser; + const MtcTime tc = feedQuarterFrames(parser, 23, 58, 59, 29, /*30fps*/ 3); + QVERIFY(tc.valid); + QCOMPARE(tc.hours, 23); + QCOMPARE(tc.minutes, 58); + QCOMPARE(tc.seconds, 59); + QCOMPARE(tc.frames, 29); + QCOMPARE(static_cast(tc.rate), 3); // Fps30 + } + + void quarterFrame_onlyCompletesOnPiece7() { + MtcParser parser; + // pieces 0..6 must not report completion + for (int piece = 0; piece < 7; ++piece) { + const unsigned char dataByte = static_cast((piece << 4) | 0x00); + QVERIFY(!parser.parseQuarterFrame(dataByte)); + } + // piece 7 completes the frame + QVERIFY(parser.parseQuarterFrame(static_cast((7 << 4) | 0x00))); + } + + void fullFrame_parsesSysex() { + // F0 7F 7F 01 01 mm ss ff F7 for 23:58:59:29 @ 30fps + const int rateHour = (3 << 5) | 23; + const std::vector sysex = { + 0xF0, 0x7F, 0x7F, 0x01, 0x01, static_cast(rateHour), + 58, 59, 29, 0xF7}; + const MtcTime tc = MtcParser::parseFullFrame(sysex); + QVERIFY(tc.valid); + QCOMPARE(tc.hours, 23); + QCOMPARE(tc.minutes, 58); + QCOMPARE(tc.seconds, 59); + QCOMPARE(tc.frames, 29); + QCOMPARE(static_cast(tc.rate), 3); + } + + void fullFrame_rejectsMalformed() { + const std::vector tooShort = {0xF0, 0x7F, 0xF7}; + QVERIFY(!MtcParser::parseFullFrame(tooShort).valid); + + std::vector wrongSubId = {0xF0, 0x7F, 0x7F, 0x02, 0x01, + 0x00, 0x00, 0x00, 0x00, 0xF7}; + QVERIFY(!MtcParser::parseFullFrame(wrongSubId).valid); + } +}; + +QTEST_MAIN(TestMtc) +#include "test_mtc.moc" diff --git a/tests/test_position.cpp b/tests/test_position.cpp new file mode 100644 index 0000000..2324f19 --- /dev/null +++ b/tests/test_position.cpp @@ -0,0 +1,114 @@ +#include "core/Cue.h" +#include "core/Position.h" +#include +#include + +using namespace OpenMix; + +class TestPosition : public QObject { + Q_OBJECT + + private slots: + void position_roundTrip() { + Position p("Downstage Left", "DSL"); + p.setPan(-0.75); + p.setDelay(12.5); + p.setBuses({1, 3, 5}); + + const QJsonObject json = p.toJson(); + const Position restored = Position::fromJson(json); + + QCOMPARE(restored.id(), p.id()); + QCOMPARE(restored.name(), QString("Downstage Left")); + QCOMPARE(restored.shortName(), QString("DSL")); + QCOMPARE(restored.pan(), -0.75); + QCOMPARE(restored.delay(), 12.5); + QCOMPARE(restored.buses(), QList({1, 3, 5})); + } + + void position_defaultsAndEmptyBuses() { + Position p("Centre"); + const Position restored = Position::fromJson(p.toJson()); + QCOMPARE(restored.pan(), 0.0); + QCOMPARE(restored.delay(), 0.0); + QVERIFY(restored.buses().isEmpty()); + } + + void library_addEmitsSignals() { + PositionLibrary lib; + QSignalSpy addSpy(&lib, &PositionLibrary::positionAdded); + QSignalSpy changedSpy(&lib, &PositionLibrary::changed); + + Position a("A"); + const QString idA = lib.addPosition(a); + + QCOMPARE(lib.count(), 1); + QCOMPARE(addSpy.count(), 1); + QCOMPARE(changedSpy.count(), 1); + QCOMPARE(addSpy.at(0).at(0).toString(), idA); + QVERIFY(lib.contains(idA)); + } + + void library_roundTrip() { + PositionLibrary lib; + Position a("A"); + Position b("B"); + b.setPan(0.5); + b.setDelay(20.0); + b.setBuses({2, 4}); + const QString idA = lib.addPosition(a); + const QString idB = lib.addPosition(b); + QCOMPARE(lib.count(), 2); + + PositionLibrary restored; + restored.loadFromJson(lib.toJson()); + + QCOMPARE(restored.count(), 2); + const auto pa = restored.position(idA); + const auto pb = restored.position(idB); + QVERIFY(pa.has_value()); + QVERIFY(pb.has_value()); + QCOMPARE(pa->name(), QString("A")); + QCOMPARE(pb->pan(), 0.5); + QCOMPARE(pb->delay(), 20.0); + QCOMPARE(pb->buses(), QList({2, 4})); + } + + void library_updateAndRemove() { + PositionLibrary lib; + Position a("A"); + const QString idA = lib.addPosition(a); + + Position edited = lib.position(idA).value(); + edited.setName("A-edited"); + edited.setPan(-0.25); + QSignalSpy changedSpy(&lib, &PositionLibrary::positionChanged); + lib.updatePosition(edited); + QCOMPARE(changedSpy.count(), 1); + QCOMPARE(lib.position(idA)->name(), QString("A-edited")); + QCOMPARE(lib.position(idA)->pan(), -0.25); + + QSignalSpy removeSpy(&lib, &PositionLibrary::positionRemoved); + QVERIFY(lib.removePosition(idA)); + QCOMPARE(removeSpy.count(), 1); + QVERIFY(lib.isEmpty()); + QVERIFY(!lib.removePosition(idA)); // already gone + } + + void cue_channelPositions_roundTrip() { + Cue cue(1.0, "Scene"); + cue.setChannelPosition(1, "pos-a"); + cue.setChannelPosition(2, "pos-b"); + cue.setChannelPosition(3, ""); // empty clears + QCOMPARE(cue.channelPositions().size(), 2); + + const Cue restored = Cue::fromJson(cue.toJson()); + QCOMPARE(restored.channelPositions().size(), 2); + QCOMPARE(restored.channelPosition(1), QString("pos-a")); + QCOMPARE(restored.channelPosition(2), QString("pos-b")); + QVERIFY(restored.channelPosition(3).isEmpty()); + } +}; + +QTEST_MAIN(TestPosition) +#include "test_position.moc" diff --git a/tests/test_timecode_trigger.cpp b/tests/test_timecode_trigger.cpp new file mode 100644 index 0000000..4c5f909 --- /dev/null +++ b/tests/test_timecode_trigger.cpp @@ -0,0 +1,88 @@ +#include "core/TimecodeTrigger.h" +#include +#include + +using namespace OpenMix; + +class TestTimecodeTrigger : public QObject { + Q_OBJECT + + private slots: + void firesWhenTimecodeReachesPoint() { + TimecodeTriggerList registry; + registry.addTrigger(0, 0, 10, 0, /*cue*/ 5.0); + QSignalSpy spy(®istry, &TimecodeTriggerList::triggerFired); + + registry.onTimecode(0, 0, 9, 29); // before + QCOMPARE(spy.count(), 0); + + registry.onTimecode(0, 0, 10, 0); // crosses the point + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(0).toDouble(), 5.0); + } + + void firesOnlyOncePerCrossing() { + TimecodeTriggerList registry; + registry.addTrigger(0, 1, 0, 0, 12.0); + QSignalSpy spy(®istry, &TimecodeTriggerList::triggerFired); + + registry.onTimecode(0, 0, 59, 29); + registry.onTimecode(0, 1, 0, 0); + registry.onTimecode(0, 1, 0, 1); + registry.onTimecode(0, 1, 0, 2); + QCOMPARE(spy.count(), 1); // armed once, fired once + } + + void firesEvenIfExactFrameSkipped() { + TimecodeTriggerList registry; + registry.addTrigger(0, 0, 5, 10, 3.0); + QSignalSpy spy(®istry, &TimecodeTriggerList::triggerFired); + + registry.onTimecode(0, 0, 5, 8); // before + registry.onTimecode(0, 0, 5, 14); // jumped past the exact frame -> still fires + QCOMPARE(spy.count(), 1); + } + + void reArmsAfterRewind() { + TimecodeTriggerList registry; + registry.addTrigger(0, 0, 10, 0, 5.0); + QSignalSpy spy(®istry, &TimecodeTriggerList::triggerFired); + + registry.onTimecode(0, 0, 9, 0); + registry.onTimecode(0, 0, 10, 0); // fire 1 + registry.onTimecode(0, 0, 5, 0); // rewind before the point (re-arm) + registry.onTimecode(0, 0, 10, 0); // fire 2 + QCOMPARE(spy.count(), 2); + } + + void disabledTriggerDoesNotFire() { + TimecodeTriggerList registry; + const QString id = registry.addTrigger(0, 0, 10, 0, 5.0); + registry.setTriggerEnabled(id, false); + QSignalSpy spy(®istry, &TimecodeTriggerList::triggerFired); + + registry.onTimecode(0, 0, 9, 0); + registry.onTimecode(0, 0, 10, 0); + QCOMPARE(spy.count(), 0); + } + + void serialization_roundTrip() { + TimecodeTriggerList registry; + registry.addTrigger(1, 2, 3, 4, 9.5); + registry.addTrigger(0, 0, 30, 0, 2.0); + + TimecodeTriggerList restored; + restored.loadFromJson(registry.toJson()); + QCOMPARE(restored.count(), 2); + + const TimecodeTrigger first = restored.triggers().at(0); + QCOMPARE(first.hours, 1); + QCOMPARE(first.minutes, 2); + QCOMPARE(first.seconds, 3); + QCOMPARE(first.frames, 4); + QCOMPARE(first.cueNumber, 9.5); + } +}; + +QTEST_MAIN(TestTimecodeTrigger) +#include "test_timecode_trigger.moc" From 5102a11d0b1eb88e9d0b4daa33544c207f310cb6 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Tue, 30 Jun 2026 19:23:38 -0400 Subject: [PATCH 04/92] feat: UI surfacing + service wiring --- CMakeLists.txt | 5 + src/app/Application.cpp | 21 + src/app/Application.h | 10 + src/ui/ActorGroupIo.h | 70 ++ src/ui/ActorSetupPanel.cpp | 1034 +++++++++++++++++++++++++++++ src/ui/ActorSetupPanel.h | 121 ++++ src/ui/CueConfidenceIndicator.cpp | 8 + src/ui/CueConfidenceIndicator.h | 4 + src/ui/CueEditor.cpp | 310 +++++++++ src/ui/CueEditor.h | 26 + src/ui/MainWindow.cpp | 57 ++ src/ui/MainWindow.h | 7 + src/ui/MixerFeedbackPanel.cpp | 73 +- src/ui/MixerFeedbackPanel.h | 14 + src/ui/RemoteControlDialog.cpp | 145 ++++ src/ui/RemoteControlDialog.h | 51 ++ src/ui/theme/Icons.h | 8 + 17 files changed, 1955 insertions(+), 9 deletions(-) create mode 100644 src/ui/ActorGroupIo.h create mode 100644 src/ui/ActorSetupPanel.cpp create mode 100644 src/ui/ActorSetupPanel.h create mode 100644 src/ui/RemoteControlDialog.cpp create mode 100644 src/ui/RemoteControlDialog.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 2cd2afc..15d5809 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -116,6 +116,8 @@ set(SOURCES src/ui/CueConfidenceIndicator.cpp src/ui/MacroPreviewWidget.cpp src/ui/DCAMappingPanel.cpp + src/ui/ActorSetupPanel.cpp + src/ui/RemoteControlDialog.cpp src/ui/PopOutWindow.cpp src/ui/BubbleButton.cpp src/ui/BubbleBar.cpp @@ -207,6 +209,9 @@ set(HEADERS src/ui/CueConfidenceIndicator.h src/ui/MacroPreviewWidget.h src/ui/DCAMappingPanel.h + src/ui/ActorSetupPanel.h + src/ui/ActorGroupIo.h + src/ui/RemoteControlDialog.h src/ui/PopOutWindow.h src/ui/BubbleButton.h src/ui/BubbleBar.h diff --git a/src/app/Application.cpp b/src/app/Application.cpp index 0d22988..1b7d0be 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -3,9 +3,13 @@ #include "QLabClient.h" #include "ui/MainWindow.h" #include "core/AppLogger.h" +#include "core/ChannelMonitor.h" #include "core/ConnectionLogBridge.h" #include "core/Cue.h" #include "core/CueList.h" +#include "core/CueZero.h" +#include "core/Position.h" +#include "core/TimecodeTrigger.h" #include "core/CueValidator.h" #include "core/DCAMapping.h" #include "core/DryRunEngine.h" @@ -66,6 +70,10 @@ Application::Application(QObject* parent) : QObject(parent) { // outbound QLab / DAW remote m_qLabClient = new QLabClient(this); + // Phase 5 services + m_timecodeTriggers = new TimecodeTriggerList(this); + m_channelMonitor = new ChannelMonitor(this); + // application logging m_appLogger = new AppLogger(this); m_connectionLogBridge = new ConnectionLogBridge(m_appLogger, this); @@ -92,6 +100,7 @@ void Application::initialize() { m_playbackEngine->setCueList(m_show->cueList()); m_playbackEngine->setDCAMapping(m_show->dcaMapping()); m_playbackEngine->setActorLibrary(m_show->actorProfileLibrary()); + m_playbackEngine->setPositionLibrary(m_show->positionLibrary()); if (m_mixer) { m_playbackEngine->setMixer(m_mixer); @@ -102,6 +111,9 @@ void Application::initialize() { m_playbackEngine->setLogger(m_playbackLogger); m_playbackEngine->setVerifyCues(true); + // Cue Zero base levels double as the panic/safe values + m_playbackGuard->setDefaultSafeValues(m_show->cueZero()->levels()); + connect(m_playbackEngine, &PlaybackEngine::cueDrifted, this, [this](int index, const QStringList& paths) { if (m_appLogger) { @@ -180,6 +192,15 @@ void Application::initialize() { if (!cue.qLabCue().isEmpty()) m_qLabClient->triggerCue(cue.qLabCue()); }); + + // timecode-triggered cues: incoming MTC -> trigger list -> fire cue by number + connect(m_midiInputManager, &MidiInputManager::timecodeChanged, m_timecodeTriggers, + &TimecodeTriggerList::onTimecode); + connect(m_timecodeTriggers, &TimecodeTriggerList::triggerFired, this, + [this](double cueNumber, const QString&) { + m_playbackEngine->goToNumber(cueNumber); + m_playbackEngine->go(); + }); } void Application::setupMixerConnection(const QString& type, const QString& host, int port) { diff --git a/src/app/Application.h b/src/app/Application.h index d4a2622..b52a391 100644 --- a/src/app/Application.h +++ b/src/app/Application.h @@ -24,6 +24,8 @@ class AppLogger; class ConnectionLogBridge; class OscRemoteServer; class QLabClient; +class TimecodeTriggerList; +class ChannelMonitor; struct DiscoveredConsole; class Application : public QObject { @@ -71,6 +73,10 @@ class Application : public QObject { // outbound QLab / DAW remote [[nodiscard]] QLabClient* qLabClient() { return m_qLabClient; } + // Phase 5 services + [[nodiscard]] TimecodeTriggerList* timecodeTriggers() { return m_timecodeTriggers; } + [[nodiscard]] ChannelMonitor* channelMonitor() { return m_channelMonitor; } + // mixer connection void connectToMixer(const QString& type, const QString& host, int port); void connectToDiscoveredConsole(const DiscoveredConsole& console); @@ -132,6 +138,10 @@ class Application : public QObject { // outbound QLab / DAW remote QLabClient* m_qLabClient; + + // Phase 5: timecode-triggered cues + channel silence/clip monitoring + TimecodeTriggerList* m_timecodeTriggers; + ChannelMonitor* m_channelMonitor; }; } // namespace OpenMix diff --git a/src/ui/ActorGroupIo.h b/src/ui/ActorGroupIo.h new file mode 100644 index 0000000..b16ffbc --- /dev/null +++ b/src/ui/ActorGroupIo.h @@ -0,0 +1,70 @@ +#pragma once + +#include "core/Actor.h" + +#include +#include +#include +#include +#include + +// Serialization helpers for exchanging actors between shows: "actor values" +// (the whole cast) and "actor groups" (a named subset), mirroring TheatreMix's +// import/export and load/save-group actions in its Actor Setup dialog. +// +// Header-only and free of any widget/Qt-GUI dependency so it links into headless +// unit tests. The on-disk format wraps the same per-actor JSON the core model +// already round-trips (Actor::toJson / fromJson), plus the show's profile slots. +namespace OpenMix::ActorGroupIo { + +inline constexpr int FORMAT_VERSION = 1; +inline constexpr const char* MAGIC_KEY = "openmix.actorGroup"; + +// Serialize a set of actors plus the profile-slot names into a portable document. +[[nodiscard]] inline QJsonObject toJson(const QList& actors, const QStringList& slotNames) { + QJsonObject doc; + doc[MAGIC_KEY] = FORMAT_VERSION; + + QJsonArray slotsArr; + for (const QString& s : slotNames) + slotsArr.append(s); + doc["slots"] = slotsArr; + + QJsonArray actorsArr; + for (const Actor& a : actors) + actorsArr.append(a.toJson()); + doc["actors"] = actorsArr; + + return doc; +} + +// True if the document looks like an actor-group export (has the magic key or an +// "actors" array). Lenient so a raw library export ("actors" only) also loads. +[[nodiscard]] inline bool isActorGroup(const QJsonObject& doc) { + return doc.contains(MAGIC_KEY) || doc.contains("actors"); +} + +// The profile-slot names stored in the document (empty if none). +[[nodiscard]] inline QStringList slotsFromJson(const QJsonObject& doc) { + QStringList slotNames; + for (const QJsonValue& v : doc["slots"].toArray()) + slotNames.append(v.toString()); + return slotNames; +} + +// Reconstruct the actors. When regenerateIds is true (the default for import, to +// avoid colliding with actors already in the target library) each actor gets a +// fresh id; pass false to preserve ids for an exact round-trip. +[[nodiscard]] inline QList actorsFromJson(const QJsonObject& doc, + bool regenerateIds = false) { + QList actors; + for (const QJsonValue& v : doc["actors"].toArray()) { + Actor a = Actor::fromJson(v.toObject()); + if (regenerateIds) + a.regenerateId(); + actors.append(a); + } + return actors; +} + +} // namespace OpenMix::ActorGroupIo diff --git a/src/ui/ActorSetupPanel.cpp b/src/ui/ActorSetupPanel.cpp new file mode 100644 index 0000000..3126659 --- /dev/null +++ b/src/ui/ActorSetupPanel.cpp @@ -0,0 +1,1034 @@ +#include "ActorSetupPanel.h" +#include "ActorGroupIo.h" +#include "app/Application.h" +#include "core/Actor.h" +#include "core/ActorProfile.h" +#include "core/ActorProfileLibrary.h" +#include "core/Show.h" +#include "protocol/MixerCapabilities.h" +#include "protocol/MixerProtocol.h" +#include "theme/Icons.h" +#include "theme/Theme.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace OpenMix { + +// --------------------------------------------------------------------------- +// VoiceEditorWidget: edits one VoiceData (a channel "voice"). Every parameter +// group is gated by a "Set ..." checkbox so the resulting VoiceData only carries +// the fields the operator actually opted into (matching the optional<> model and +// TheatreMix's partial-voice semantics). Plain QWidget (no signals) — change +// notification is delivered through the onChanged callback so no moc is needed. +// --------------------------------------------------------------------------- +class VoiceEditorWidget : public QWidget { + public: + explicit VoiceEditorWidget(QWidget* parent = nullptr) : QWidget(parent) { build(); } + + std::function onChanged; + + void setVoice(const VoiceData& v) { + m_updating = true; + + m_setGain->setChecked(v.gainDb.has_value()); + m_gain->setValue(v.gainDb.value_or(0.0)); + + const bool hasHpf = v.hpfOn.has_value() || v.hpfFreq.has_value(); + m_setHpf->setChecked(hasHpf); + m_hpfOn->setChecked(v.hpfOn.value_or(true)); + m_hpfFreq->setValue(v.hpfFreq.value_or(80.0)); + + const bool hasEq = v.eqOn.has_value() || !v.eqBands.isEmpty(); + m_setEq->setChecked(hasEq); + m_eqOn->setChecked(v.eqOn.value_or(true)); + m_eqTable->setRowCount(0); + for (const EqBand& b : v.eqBands) + addEqRow(b); + + const bool hasDyn = v.dynOn.has_value() || v.dynThreshold.has_value() || + v.dynRatio.has_value() || v.dynAttack.has_value() || + v.dynRelease.has_value() || v.dynGain.has_value(); + m_setDyn->setChecked(hasDyn); + m_dynOn->setChecked(v.dynOn.value_or(true)); + m_thr->setValue(v.dynThreshold.value_or(-20.0)); + m_ratio->setValue(v.dynRatio.value_or(3.0)); + m_att->setValue(v.dynAttack.value_or(10.0)); + m_rel->setValue(v.dynRelease.value_or(100.0)); + m_makeup->setValue(v.dynGain.value_or(0.0)); + + updateEnabledStates(); + m_updating = false; + } + + [[nodiscard]] VoiceData voice() const { + VoiceData v; + if (m_setGain->isChecked()) + v.gainDb = m_gain->value(); + if (m_setHpf->isChecked()) { + v.hpfOn = m_hpfOn->isChecked(); + v.hpfFreq = m_hpfFreq->value(); + } + if (m_setEq->isChecked()) { + v.eqOn = m_eqOn->isChecked(); + for (int r = 0; r < m_eqTable->rowCount(); ++r) { + EqBand b; + b.band = cellSpin(r, 0)->value(); + b.on = cellCheck(r, 1)->isChecked(); + b.type = cellCombo(r, 2)->currentIndex(); + b.freq = cellDouble(r, 3)->value(); + b.gain = cellDouble(r, 4)->value(); + b.q = cellDouble(r, 5)->value(); + v.eqBands.append(b); + } + } + if (m_setDyn->isChecked()) { + v.dynOn = m_dynOn->isChecked(); + v.dynThreshold = m_thr->value(); + v.dynRatio = m_ratio->value(); + v.dynAttack = m_att->value(); + v.dynRelease = m_rel->value(); + v.dynGain = m_makeup->value(); + } + return v; + } + + private: + void notify() { + if (!m_updating && onChanged) + onChanged(); + } + + QDoubleSpinBox* makeDouble(double lo, double hi, int decimals, double step, + const QString& suffix = QString()) { + auto* s = new QDoubleSpinBox(this); + s->setRange(lo, hi); + s->setDecimals(decimals); + s->setSingleStep(step); + if (!suffix.isEmpty()) + s->setSuffix(suffix); + connect(s, QOverload::of(&QDoubleSpinBox::valueChanged), this, + [this](double) { notify(); }); + return s; + } + + void build() { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(6, 6, 6, 6); + layout->setSpacing(8); + + // gain + auto* gainBox = new QGroupBox(tr("Preamp Gain"), this); + auto* gainLayout = new QHBoxLayout(gainBox); + m_setGain = new QCheckBox(tr("Set"), gainBox); + m_gain = makeDouble(-90.0, 30.0, 1, 0.5, tr(" dB")); + gainLayout->addWidget(m_setGain); + gainLayout->addWidget(m_gain, 1); + layout->addWidget(gainBox); + + // HPF + auto* hpfBox = new QGroupBox(tr("High-Pass Filter"), this); + auto* hpfLayout = new QHBoxLayout(hpfBox); + m_setHpf = new QCheckBox(tr("Set"), hpfBox); + m_hpfOn = new QCheckBox(tr("On"), hpfBox); + m_hpfFreq = makeDouble(20.0, 2000.0, 0, 5.0, tr(" Hz")); + hpfLayout->addWidget(m_setHpf); + hpfLayout->addWidget(m_hpfOn); + hpfLayout->addWidget(m_hpfFreq, 1); + layout->addWidget(hpfBox); + + // EQ + auto* eqBox = new QGroupBox(tr("Parametric EQ"), this); + auto* eqLayout = new QVBoxLayout(eqBox); + auto* eqHeader = new QHBoxLayout(); + m_setEq = new QCheckBox(tr("Set"), eqBox); + m_eqOn = new QCheckBox(tr("On"), eqBox); + m_addBandBtn = new QPushButton(Icons::listAdd(), tr("Add Band"), eqBox); + m_delBandBtn = new QPushButton(Icons::listRemove(), tr("Remove Band"), eqBox); + eqHeader->addWidget(m_setEq); + eqHeader->addWidget(m_eqOn); + eqHeader->addStretch(); + eqHeader->addWidget(m_addBandBtn); + eqHeader->addWidget(m_delBandBtn); + eqLayout->addLayout(eqHeader); + + m_eqTable = new QTableWidget(0, 6, eqBox); + m_eqTable->setHorizontalHeaderLabels( + {tr("Band"), tr("On"), tr("Type"), tr("Freq"), tr("Gain"), tr("Q")}); + m_eqTable->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + m_eqTable->verticalHeader()->setVisible(false); + m_eqTable->setSelectionBehavior(QAbstractItemView::SelectRows); + m_eqTable->setMinimumHeight(120); + eqLayout->addWidget(m_eqTable); + layout->addWidget(eqBox); + + connect(m_addBandBtn, &QPushButton::clicked, this, [this]() { + EqBand b; + b.band = m_eqTable->rowCount() + 1; + addEqRow(b); + notify(); + }); + connect(m_delBandBtn, &QPushButton::clicked, this, [this]() { + int row = m_eqTable->currentRow(); + if (row < 0) + row = m_eqTable->rowCount() - 1; + if (row >= 0) { + m_eqTable->removeRow(row); + notify(); + } + }); + + // dynamics + auto* dynBox = new QGroupBox(tr("Dynamics"), this); + auto* dynLayout = new QFormLayout(dynBox); + auto* dynTop = new QHBoxLayout(); + m_setDyn = new QCheckBox(tr("Set"), dynBox); + m_dynOn = new QCheckBox(tr("On"), dynBox); + dynTop->addWidget(m_setDyn); + dynTop->addWidget(m_dynOn); + dynTop->addStretch(); + dynLayout->addRow(dynTop); + m_thr = makeDouble(-60.0, 0.0, 1, 0.5, tr(" dB")); + m_ratio = makeDouble(1.0, 20.0, 1, 0.1, tr(":1")); + m_att = makeDouble(0.1, 200.0, 1, 1.0, tr(" ms")); + m_rel = makeDouble(5.0, 2000.0, 0, 5.0, tr(" ms")); + m_makeup = makeDouble(0.0, 24.0, 1, 0.5, tr(" dB")); + dynLayout->addRow(tr("Threshold:"), m_thr); + dynLayout->addRow(tr("Ratio:"), m_ratio); + dynLayout->addRow(tr("Attack:"), m_att); + dynLayout->addRow(tr("Release:"), m_rel); + dynLayout->addRow(tr("Makeup:"), m_makeup); + layout->addWidget(dynBox); + + layout->addStretch(); + + // gate value widgets behind their "Set" checkbox & notify on every edit + for (QCheckBox* set : {m_setGain, m_setHpf, m_setEq, m_setDyn}) { + connect(set, &QCheckBox::toggled, this, [this]() { + updateEnabledStates(); + notify(); + }); + } + for (QCheckBox* val : {m_hpfOn, m_eqOn, m_dynOn}) { + connect(val, &QCheckBox::toggled, this, [this]() { notify(); }); + } + } + + void addEqRow(const EqBand& b) { + const int row = m_eqTable->rowCount(); + m_eqTable->insertRow(row); + + auto* band = new QSpinBox(m_eqTable); + band->setRange(1, 32); + band->setValue(b.band); + connect(band, QOverload::of(&QSpinBox::valueChanged), this, [this](int) { notify(); }); + m_eqTable->setCellWidget(row, 0, band); + + auto* on = new QCheckBox(m_eqTable); + on->setChecked(b.on); + connect(on, &QCheckBox::toggled, this, [this]() { notify(); }); + m_eqTable->setCellWidget(row, 1, on); + + auto* type = new QComboBox(m_eqTable); + type->addItems({tr("PEQ"), tr("Lo Shelf"), tr("Hi Shelf"), tr("HPF"), tr("LPF")}); + type->setCurrentIndex(std::clamp(b.type, 0, type->count() - 1)); + connect(type, QOverload::of(&QComboBox::currentIndexChanged), this, + [this](int) { notify(); }); + m_eqTable->setCellWidget(row, 2, type); + + auto* freq = new QDoubleSpinBox(m_eqTable); + freq->setRange(20.0, 20000.0); + freq->setDecimals(0); + freq->setValue(b.freq); + connect(freq, QOverload::of(&QDoubleSpinBox::valueChanged), this, + [this](double) { notify(); }); + m_eqTable->setCellWidget(row, 3, freq); + + auto* gain = new QDoubleSpinBox(m_eqTable); + gain->setRange(-18.0, 18.0); + gain->setDecimals(1); + gain->setValue(b.gain); + connect(gain, QOverload::of(&QDoubleSpinBox::valueChanged), this, + [this](double) { notify(); }); + m_eqTable->setCellWidget(row, 4, gain); + + auto* q = new QDoubleSpinBox(m_eqTable); + q->setRange(0.1, 10.0); + q->setDecimals(2); + q->setValue(b.q); + connect(q, QOverload::of(&QDoubleSpinBox::valueChanged), this, + [this](double) { notify(); }); + m_eqTable->setCellWidget(row, 5, q); + } + + QSpinBox* cellSpin(int row, int col) const { + return qobject_cast(m_eqTable->cellWidget(row, col)); + } + QCheckBox* cellCheck(int row, int col) const { + return qobject_cast(m_eqTable->cellWidget(row, col)); + } + QComboBox* cellCombo(int row, int col) const { + return qobject_cast(m_eqTable->cellWidget(row, col)); + } + QDoubleSpinBox* cellDouble(int row, int col) const { + return qobject_cast(m_eqTable->cellWidget(row, col)); + } + + void updateEnabledStates() { + m_gain->setEnabled(m_setGain->isChecked()); + m_hpfOn->setEnabled(m_setHpf->isChecked()); + m_hpfFreq->setEnabled(m_setHpf->isChecked()); + const bool eq = m_setEq->isChecked(); + m_eqOn->setEnabled(eq); + m_eqTable->setEnabled(eq); + m_addBandBtn->setEnabled(eq); + m_delBandBtn->setEnabled(eq); + const bool dyn = m_setDyn->isChecked(); + m_dynOn->setEnabled(dyn); + for (QDoubleSpinBox* s : {m_thr, m_ratio, m_att, m_rel, m_makeup}) + s->setEnabled(dyn); + } + + bool m_updating = false; + + QCheckBox* m_setGain = nullptr; + QDoubleSpinBox* m_gain = nullptr; + QCheckBox* m_setHpf = nullptr; + QCheckBox* m_hpfOn = nullptr; + QDoubleSpinBox* m_hpfFreq = nullptr; + QCheckBox* m_setEq = nullptr; + QCheckBox* m_eqOn = nullptr; + QTableWidget* m_eqTable = nullptr; + QPushButton* m_addBandBtn = nullptr; + QPushButton* m_delBandBtn = nullptr; + QCheckBox* m_setDyn = nullptr; + QCheckBox* m_dynOn = nullptr; + QDoubleSpinBox* m_thr = nullptr; + QDoubleSpinBox* m_ratio = nullptr; + QDoubleSpinBox* m_att = nullptr; + QDoubleSpinBox* m_rel = nullptr; + QDoubleSpinBox* m_makeup = nullptr; +}; + +// --------------------------------------------------------------------------- +// ActorSetupPanel +// --------------------------------------------------------------------------- + +ActorSetupPanel::ActorSetupPanel(Application* app, QWidget* parent) : QWidget(parent), m_app(app) { + if (m_app && m_app->show()) + m_library = m_app->show()->actorProfileLibrary(); + + setupUi(); + loadDisplayOptions(); + + if (m_library) { + connect(m_library, &ActorProfileLibrary::changed, this, + &ActorSetupPanel::onLibraryChanged); + } + + refresh(); +} + +void ActorSetupPanel::setupUi() { + auto* mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(8, 8, 8, 8); + mainLayout->setSpacing(8); + + // toolbar: copy/paste + import/export + groups + auto* toolbar = new QHBoxLayout(); + m_copyBtn = new QPushButton(Icons::copy(), tr("Copy Values"), this); + m_copyBtn->setToolTip(tr("Copy the selected actor's voice values")); + m_pasteBtn = new QPushButton(Icons::paste(), tr("Paste Values"), this); + m_pasteBtn->setToolTip(tr("Apply copied voice values to the selected actor")); + m_importBtn = new QPushButton(Icons::upload(), tr("Import..."), this); + m_importBtn->setToolTip(tr("Replace the cast with actor values from a file")); + m_exportBtn = new QPushButton(Icons::download(), tr("Export..."), this); + m_exportBtn->setToolTip(tr("Export all actor values to a file")); + m_loadGroupBtn = new QPushButton(Icons::actor(), tr("Load Group..."), this); + m_loadGroupBtn->setToolTip(tr("Append actors from an actor-group file")); + m_saveGroupBtn = new QPushButton(Icons::actorSetup(), tr("Save Group..."), this); + m_saveGroupBtn->setToolTip(tr("Save the selected actor (or whole cast) as an actor group")); + toolbar->addWidget(m_copyBtn); + toolbar->addWidget(m_pasteBtn); + toolbar->addSpacing(12); + toolbar->addWidget(m_importBtn); + toolbar->addWidget(m_exportBtn); + toolbar->addSpacing(12); + toolbar->addWidget(m_loadGroupBtn); + toolbar->addWidget(m_saveGroupBtn); + toolbar->addStretch(); + mainLayout->addLayout(toolbar); + + connect(m_copyBtn, &QPushButton::clicked, this, &ActorSetupPanel::copyActorValues); + connect(m_pasteBtn, &QPushButton::clicked, this, &ActorSetupPanel::pasteActorValues); + connect(m_importBtn, &QPushButton::clicked, this, &ActorSetupPanel::importActorValues); + connect(m_exportBtn, &QPushButton::clicked, this, &ActorSetupPanel::exportActorValues); + connect(m_loadGroupBtn, &QPushButton::clicked, this, &ActorSetupPanel::loadActorGroup); + connect(m_saveGroupBtn, &QPushButton::clicked, this, &ActorSetupPanel::saveActorGroup); + + auto* splitter = new QSplitter(Qt::Horizontal, this); + + // ---- left: actor tree + list controls + display options ---- + auto* left = new QWidget(splitter); + auto* leftLayout = new QVBoxLayout(left); + leftLayout->setContentsMargins(0, 0, 0, 0); + + m_actorTree = new QTreeWidget(left); + m_actorTree->setColumnCount(3); + m_actorTree->setHeaderLabels({tr("Actor"), tr("Ch"), tr("Active")}); + m_actorTree->setRootIsDecorated(false); + m_actorTree->setSelectionMode(QAbstractItemView::SingleSelection); + m_actorTree->header()->setStretchLastSection(false); + m_actorTree->header()->setSectionResizeMode(0, QHeaderView::Stretch); + m_actorTree->header()->setSectionResizeMode(1, QHeaderView::ResizeToContents); + m_actorTree->header()->setSectionResizeMode(2, QHeaderView::ResizeToContents); + connect(m_actorTree, &QTreeWidget::itemSelectionChanged, this, + &ActorSetupPanel::onActorSelectionChanged); + leftLayout->addWidget(m_actorTree, 1); + + auto* listButtons = new QHBoxLayout(); + m_addActorBtn = new QPushButton(Icons::listAdd(), tr("Add"), left); + m_removeActorBtn = new QPushButton(Icons::listRemove(), tr("Remove"), left); + m_moveUpBtn = new QPushButton(Icons::moveUp(), QString(), left); + m_moveUpBtn->setToolTip(tr("Move actor up")); + m_moveDownBtn = new QPushButton(Icons::moveDown(), QString(), left); + m_moveDownBtn->setToolTip(tr("Move actor down")); + listButtons->addWidget(m_addActorBtn); + listButtons->addWidget(m_removeActorBtn); + listButtons->addStretch(); + listButtons->addWidget(m_moveUpBtn); + listButtons->addWidget(m_moveDownBtn); + leftLayout->addLayout(listButtons); + + connect(m_addActorBtn, &QPushButton::clicked, this, &ActorSetupPanel::addActor); + connect(m_removeActorBtn, &QPushButton::clicked, this, &ActorSetupPanel::removeActor); + connect(m_moveUpBtn, &QPushButton::clicked, this, &ActorSetupPanel::moveActorUp); + connect(m_moveDownBtn, &QPushButton::clicked, this, &ActorSetupPanel::moveActorDown); + + auto* optionsBox = new QGroupBox(tr("Check-Cue Display"), left); + auto* optionsLayout = new QVBoxLayout(optionsBox); + m_scribbleNamesCheck = + new QCheckBox(tr("Show actor names on scribble strips in check cues"), optionsBox); + m_cueZeroLabelsCheck = new QCheckBox(tr("Show actor labels on cue zero"), optionsBox); + optionsLayout->addWidget(m_scribbleNamesCheck); + optionsLayout->addWidget(m_cueZeroLabelsCheck); + leftLayout->addWidget(optionsBox); + + auto saveOptions = [this]() { + QSettings settings("OpenMix", "OpenMix"); + settings.beginGroup("ActorSetup"); + settings.setValue("scribbleNames", m_scribbleNamesCheck->isChecked()); + settings.setValue("cueZeroLabels", m_cueZeroLabelsCheck->isChecked()); + settings.endGroup(); + }; + connect(m_scribbleNamesCheck, &QCheckBox::toggled, this, saveOptions); + connect(m_cueZeroLabelsCheck, &QCheckBox::toggled, this, saveOptions); + + splitter->addWidget(left); + + // ---- right: actor editor ---- + auto* scroll = new QScrollArea(splitter); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + + m_editor = new QWidget(); + auto* editorLayout = new QVBoxLayout(m_editor); + editorLayout->setContentsMargins(4, 4, 4, 4); + + auto* identityBox = new QGroupBox(tr("Actor"), m_editor); + auto* identityForm = new QFormLayout(identityBox); + m_nameEdit = new QLineEdit(identityBox); + m_nameEdit->setPlaceholderText(tr("Actor name")); + m_channelSpin = new QSpinBox(identityBox); + m_channelSpin->setRange(1, 96); + m_activeCheck = new QCheckBox(tr("Active"), identityBox); + m_activeCheck->setToolTip(tr("Inactive actors yield their channel to the next understudy")); + m_backupCheck = new QCheckBox(tr("Channel on backup / spare mic"), identityBox); + m_backupCheck->setToolTip(tr("Resolve this channel to the backup voice instead of the main")); + identityForm->addRow(tr("Name:"), m_nameEdit); + identityForm->addRow(tr("Channel:"), m_channelSpin); + identityForm->addRow(QString(), m_activeCheck); + identityForm->addRow(QString(), m_backupCheck); + editorLayout->addWidget(identityBox); + + connect(m_nameEdit, &QLineEdit::textChanged, this, &ActorSetupPanel::onNameChanged); + connect(m_channelSpin, QOverload::of(&QSpinBox::valueChanged), this, + &ActorSetupPanel::onChannelChanged); + connect(m_activeCheck, &QCheckBox::toggled, this, &ActorSetupPanel::onActiveToggled); + connect(m_backupCheck, &QCheckBox::toggled, this, &ActorSetupPanel::onBackupToggled); + + auto* slotBox = new QGroupBox(tr("Profile Slot"), m_editor); + auto* slotLayout = new QHBoxLayout(slotBox); + m_slotCombo = new QComboBox(slotBox); + m_addSlotBtn = new QPushButton(Icons::listAdd(), QString(), slotBox); + m_addSlotBtn->setToolTip(tr("Add a profile slot")); + m_removeSlotBtn = new QPushButton(Icons::listRemove(), QString(), slotBox); + m_removeSlotBtn->setToolTip(tr("Remove the current profile slot")); + slotLayout->addWidget(m_slotCombo, 1); + slotLayout->addWidget(m_addSlotBtn); + slotLayout->addWidget(m_removeSlotBtn); + editorLayout->addWidget(slotBox); + + connect(m_slotCombo, QOverload::of(&QComboBox::currentIndexChanged), this, + &ActorSetupPanel::onSlotChanged); + connect(m_addSlotBtn, &QPushButton::clicked, this, &ActorSetupPanel::addSlot); + connect(m_removeSlotBtn, &QPushButton::clicked, this, &ActorSetupPanel::removeSlot); + + m_voiceTabs = new QTabWidget(m_editor); + m_mainVoice = new VoiceEditorWidget(m_voiceTabs); + m_backupVoice = new VoiceEditorWidget(m_voiceTabs); + m_mainVoice->onChanged = [this]() { commitVoiceEdits(); }; + m_backupVoice->onChanged = [this]() { commitVoiceEdits(); }; + m_voiceTabs->addTab(m_mainVoice, tr("Main Voice")); + m_voiceTabs->addTab(m_backupVoice, tr("Backup Voice")); + editorLayout->addWidget(m_voiceTabs, 1); + + scroll->setWidget(m_editor); + splitter->addWidget(scroll); + + splitter->setStretchFactor(0, 0); + splitter->setStretchFactor(1, 1); + splitter->setSizes({260, 460}); + mainLayout->addWidget(splitter, 1); +} + +void ActorSetupPanel::loadDisplayOptions() { + QSettings settings("OpenMix", "OpenMix"); + settings.beginGroup("ActorSetup"); + QSignalBlocker b1(m_scribbleNamesCheck); + QSignalBlocker b2(m_cueZeroLabelsCheck); + m_scribbleNamesCheck->setChecked(settings.value("scribbleNames", true).toBool()); + m_cueZeroLabelsCheck->setChecked(settings.value("cueZeroLabels", false).toBool()); + settings.endGroup(); +} + +int ActorSetupPanel::channelCount() const { + if (m_app && m_app->mixer() && m_app->mixer()->isConnected()) + return std::max(1, m_app->mixer()->capabilities().inputChannels); + return 96; +} + +QString ActorSetupPanel::selectedActorId() const { + auto* item = m_actorTree->currentItem(); + return item ? item->data(0, Qt::UserRole).toString() : QString(); +} + +void ActorSetupPanel::refresh() { + if (m_app && m_app->show()) + m_library = m_app->show()->actorProfileLibrary(); + m_channelSpin->setMaximum(channelCount()); + rebuildActorTree(selectedActorId()); +} + +void ActorSetupPanel::rebuildActorTree(const QString& selectId) { + if (!m_library) + return; + + m_updatingUi = true; + m_actorTree->clear(); + + // present actors ordered by their order field (lowest first = top of list) + QList actors = m_library->actors(); + std::stable_sort(actors.begin(), actors.end(), + [](const Actor& a, const Actor& b) { return a.order() < b.order(); }); + + QTreeWidgetItem* toSelect = nullptr; + for (const Actor& a : actors) { + auto* item = new QTreeWidgetItem(m_actorTree); + item->setText(0, a.name().isEmpty() ? tr("(unnamed)") : a.name()); + item->setText(1, QString::number(a.channel())); + item->setText(2, a.active() ? tr("Yes") : tr("No")); + item->setData(0, Qt::UserRole, a.id()); + if (a.id() == selectId) + toSelect = item; + } + + if (!toSelect && m_actorTree->topLevelItemCount() > 0) + toSelect = m_actorTree->topLevelItem(0); + + m_updatingUi = false; + + if (toSelect) + m_actorTree->setCurrentItem(toSelect); + else + loadActorIntoEditor(); + + updateButtonStates(); +} + +void ActorSetupPanel::rebuildSlotCombo() { + if (!m_library) + return; + m_updatingUi = true; + m_slotCombo->clear(); + const QStringList slotNames = m_library->profileSlots(); + m_slotCombo->addItems(slotNames); + if (!slotNames.contains(m_currentSlot)) + m_currentSlot = slotNames.isEmpty() ? QString() : slotNames.first(); + m_slotCombo->setCurrentText(m_currentSlot); + m_updatingUi = false; +} + +void ActorSetupPanel::onActorSelectionChanged() { + if (m_updatingUi) + return; + loadActorIntoEditor(); + updateButtonStates(); +} + +void ActorSetupPanel::loadActorIntoEditor() { + const QString id = selectedActorId(); + const Actor* a = m_library ? m_library->actorById(id) : nullptr; + + if (!a) { + setEditorEnabled(false); + m_updatingUi = true; + m_nameEdit->clear(); + m_channelSpin->setValue(1); + m_activeCheck->setChecked(false); + m_backupCheck->setChecked(false); + m_mainVoice->setVoice(VoiceData()); + m_backupVoice->setVoice(VoiceData()); + m_updatingUi = false; + return; + } + + setEditorEnabled(true); + rebuildSlotCombo(); + + m_updatingUi = true; + m_nameEdit->setText(a->name()); + m_channelSpin->setValue(a->channel()); + m_activeCheck->setChecked(a->active()); + m_backupCheck->setChecked(m_library->isBackup(a->channel())); + + const ActorProfile profile = a->profile(m_currentSlot); + m_mainVoice->setVoice(profile.main()); + m_backupVoice->setVoice(profile.backup()); + m_updatingUi = false; +} + +void ActorSetupPanel::setEditorEnabled(bool on) { + m_editor->setEnabled(on); +} + +void ActorSetupPanel::updateButtonStates() { + const bool hasSel = !selectedActorId().isEmpty(); + m_removeActorBtn->setEnabled(hasSel); + m_copyBtn->setEnabled(hasSel); + m_pasteBtn->setEnabled(hasSel && !m_copiedProfiles.isEmpty()); + m_saveGroupBtn->setEnabled(m_library && m_library->actorCount() > 0); + + auto* item = m_actorTree->currentItem(); + const int idx = item ? m_actorTree->indexOfTopLevelItem(item) : -1; + m_moveUpBtn->setEnabled(idx > 0); + m_moveDownBtn->setEnabled(idx >= 0 && idx < m_actorTree->topLevelItemCount() - 1); +} + +void ActorSetupPanel::addActor() { + if (!m_library) + return; + + // choose the lowest free channel and a unique-ish order at the end + QSet used; + int maxOrder = 0; + for (const Actor& a : m_library->actors()) { + used.insert(a.channel()); + maxOrder = std::max(maxOrder, a.order()); + } + int channel = 1; + while (used.contains(channel) && channel < channelCount()) + ++channel; + + Actor actor(tr("New Actor"), channel); + actor.setOrder(maxOrder + 1); + // seed an empty profile for the current slots so the slot combo is populated + const QStringList slotNames = m_library->profileSlots(); + if (!slotNames.isEmpty()) + actor.setProfile(slotNames.first(), ActorProfile()); + + const QString id = actor.id(); + m_library->addActor(actor); + rebuildActorTree(id); + m_nameEdit->setFocus(); + m_nameEdit->selectAll(); +} + +void ActorSetupPanel::removeActor() { + const QString id = selectedActorId(); + if (id.isEmpty() || !m_library) + return; + + const Actor* a = m_library->actorById(id); + const QString name = a ? a->name() : QString(); + if (QMessageBox::question(this, tr("Remove Actor"), + tr("Remove actor \"%1\"?").arg(name)) != QMessageBox::Yes) + return; + + m_library->removeActor(id); + rebuildActorTree(); +} + +void ActorSetupPanel::moveActorUp() { + auto* item = m_actorTree->currentItem(); + if (!item || !m_library) + return; + const int idx = m_actorTree->indexOfTopLevelItem(item); + if (idx <= 0) + return; + + const QString idA = item->data(0, Qt::UserRole).toString(); + const QString idB = m_actorTree->topLevelItem(idx - 1)->data(0, Qt::UserRole).toString(); + const Actor* a = m_library->actorById(idA); + const Actor* b = m_library->actorById(idB); + if (!a || !b) + return; + + Actor ca = *a, cb = *b; + const int oa = ca.order(); + ca.setOrder(cb.order()); + cb.setOrder(oa); + m_library->updateActor(idA, ca); + m_library->updateActor(idB, cb); + rebuildActorTree(idA); +} + +void ActorSetupPanel::moveActorDown() { + auto* item = m_actorTree->currentItem(); + if (!item || !m_library) + return; + const int idx = m_actorTree->indexOfTopLevelItem(item); + if (idx < 0 || idx >= m_actorTree->topLevelItemCount() - 1) + return; + + const QString idA = item->data(0, Qt::UserRole).toString(); + const QString idB = m_actorTree->topLevelItem(idx + 1)->data(0, Qt::UserRole).toString(); + const Actor* a = m_library->actorById(idA); + const Actor* b = m_library->actorById(idB); + if (!a || !b) + return; + + Actor ca = *a, cb = *b; + const int oa = ca.order(); + ca.setOrder(cb.order()); + cb.setOrder(oa); + m_library->updateActor(idA, ca); + m_library->updateActor(idB, cb); + rebuildActorTree(idA); +} + +void ActorSetupPanel::onNameChanged(const QString& text) { + if (m_updatingUi) + return; + const QString id = selectedActorId(); + const Actor* a = m_library ? m_library->actorById(id) : nullptr; + if (!a) + return; + Actor copy = *a; + copy.setName(text); + m_updatingUi = true; + m_library->updateActor(id, copy); + m_updatingUi = false; + if (auto* item = m_actorTree->currentItem()) + item->setText(0, text.isEmpty() ? tr("(unnamed)") : text); +} + +void ActorSetupPanel::onChannelChanged(int channel) { + if (m_updatingUi) + return; + const QString id = selectedActorId(); + const Actor* a = m_library ? m_library->actorById(id) : nullptr; + if (!a) + return; + Actor copy = *a; + copy.setChannel(channel); + m_updatingUi = true; + m_library->updateActor(id, copy); + m_backupCheck->setChecked(m_library->isBackup(channel)); // backup is per-channel + m_updatingUi = false; + if (auto* item = m_actorTree->currentItem()) + item->setText(1, QString::number(channel)); +} + +void ActorSetupPanel::onActiveToggled(bool on) { + if (m_updatingUi) + return; + const QString id = selectedActorId(); + const Actor* a = m_library ? m_library->actorById(id) : nullptr; + if (!a) + return; + Actor copy = *a; + copy.setActive(on); + m_updatingUi = true; + m_library->updateActor(id, copy); + m_updatingUi = false; + if (auto* item = m_actorTree->currentItem()) + item->setText(2, on ? tr("Yes") : tr("No")); +} + +void ActorSetupPanel::onBackupToggled(bool on) { + if (m_updatingUi) + return; + const QString id = selectedActorId(); + const Actor* a = m_library ? m_library->actorById(id) : nullptr; + if (!a) + return; + m_updatingUi = true; + m_library->setBackup(a->channel(), on); + m_updatingUi = false; +} + +void ActorSetupPanel::onSlotChanged(int) { + if (m_updatingUi) + return; + m_currentSlot = m_slotCombo->currentText(); + // reload the selected actor's voices for the newly chosen slot + const QString id = selectedActorId(); + const Actor* a = m_library ? m_library->actorById(id) : nullptr; + if (!a) + return; + const ActorProfile profile = a->profile(m_currentSlot); + m_updatingUi = true; + m_mainVoice->setVoice(profile.main()); + m_backupVoice->setVoice(profile.backup()); + m_updatingUi = false; +} + +void ActorSetupPanel::addSlot() { + if (!m_library) + return; + bool ok = false; + const QString name = + QInputDialog::getText(this, tr("Add Profile Slot"), tr("Slot name:"), QLineEdit::Normal, + QString(), &ok) + .trimmed(); + if (!ok || name.isEmpty()) + return; + if (m_library->profileSlots().contains(name)) { + QMessageBox::information(this, tr("Add Profile Slot"), + tr("A slot named \"%1\" already exists.").arg(name)); + return; + } + m_library->addSlot(name); + m_currentSlot = name; + rebuildSlotCombo(); + loadActorIntoEditor(); +} + +void ActorSetupPanel::removeSlot() { + if (!m_library || m_currentSlot.isEmpty()) + return; + if (m_library->profileSlots().size() <= 1) { + QMessageBox::information(this, tr("Remove Profile Slot"), + tr("At least one profile slot is required.")); + return; + } + if (QMessageBox::question(this, tr("Remove Profile Slot"), + tr("Remove profile slot \"%1\"? Stored voices for this slot are " + "kept but no longer applied.") + .arg(m_currentSlot)) != QMessageBox::Yes) + return; + m_library->removeSlot(m_currentSlot); + m_currentSlot.clear(); + rebuildSlotCombo(); + loadActorIntoEditor(); +} + +void ActorSetupPanel::commitVoiceEdits() { + if (m_updatingUi) + return; + const QString id = selectedActorId(); + const Actor* a = m_library ? m_library->actorById(id) : nullptr; + if (!a || m_currentSlot.isEmpty()) + return; + Actor copy = *a; + ActorProfile profile = copy.profile(m_currentSlot); + profile.setMain(m_mainVoice->voice()); + profile.setBackup(m_backupVoice->voice()); + copy.setProfile(m_currentSlot, profile); + m_updatingUi = true; + m_library->updateActor(id, copy); + m_updatingUi = false; +} + +void ActorSetupPanel::copyActorValues() { + const QString id = selectedActorId(); + const Actor* a = m_library ? m_library->actorById(id) : nullptr; + if (!a) + return; + // capture just the per-slot profiles (the "voice values"), not identity + m_copiedProfiles = a->toJson().value("profiles").toObject(); + updateButtonStates(); +} + +void ActorSetupPanel::pasteActorValues() { + const QString id = selectedActorId(); + const Actor* a = m_library ? m_library->actorById(id) : nullptr; + if (!a || m_copiedProfiles.isEmpty()) + return; + + Actor copy = *a; + for (auto it = m_copiedProfiles.constBegin(); it != m_copiedProfiles.constEnd(); ++it) { + copy.setProfile(it.key(), ActorProfile::fromJson(it.value().toObject())); + if (!m_library->profileSlots().contains(it.key())) + m_library->addSlot(it.key()); + } + m_library->updateActor(id, copy); + loadActorIntoEditor(); +} + +void ActorSetupPanel::exportActorValues() { + if (!m_library) + return; + QString path = QFileDialog::getSaveFileName(this, tr("Export Actor Values"), QString(), + tr("OpenMix Actors (*.omactors)")); + if (path.isEmpty()) + return; + if (!path.endsWith(".omactors")) + path += ".omactors"; + + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + QMessageBox::warning(this, tr("Export Actor Values"), + tr("Failed to write file: %1").arg(file.errorString())); + return; + } + file.write(QJsonDocument(m_library->toJson()).toJson(QJsonDocument::Indented)); +} + +void ActorSetupPanel::importActorValues() { + if (!m_library) + return; + QString path = QFileDialog::getOpenFileName(this, tr("Import Actor Values"), QString(), + tr("OpenMix Actors (*.omactors);;All Files (*)")); + if (path.isEmpty()) + return; + + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + QMessageBox::warning(this, tr("Import Actor Values"), + tr("Failed to read file: %1").arg(file.errorString())); + return; + } + QJsonParseError err; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) { + QMessageBox::warning(this, tr("Import Actor Values"), + tr("Invalid actor file: %1").arg(err.errorString())); + return; + } + if (m_library->actorCount() > 0 && + QMessageBox::question(this, tr("Import Actor Values"), + tr("Replace the current cast with the imported actors?")) != + QMessageBox::Yes) + return; + + m_library->loadFromJson(doc.object()); + rebuildActorTree(); +} + +void ActorSetupPanel::saveActorGroup() { + if (!m_library || m_library->actorCount() == 0) + return; + + // save the selected actor if one is chosen, otherwise the whole cast + QList toSave; + const QString id = selectedActorId(); + if (const Actor* sel = m_library->actorById(id)) + toSave.append(*sel); + else + toSave = m_library->actors(); + + QString path = QFileDialog::getSaveFileName(this, tr("Save Actor Group"), QString(), + tr("OpenMix Actor Group (*.omgroup)")); + if (path.isEmpty()) + return; + if (!path.endsWith(".omgroup")) + path += ".omgroup"; + + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + QMessageBox::warning(this, tr("Save Actor Group"), + tr("Failed to write file: %1").arg(file.errorString())); + return; + } + const QJsonObject doc = ActorGroupIo::toJson(toSave, m_library->profileSlots()); + file.write(QJsonDocument(doc).toJson(QJsonDocument::Indented)); +} + +void ActorSetupPanel::loadActorGroup() { + if (!m_library) + return; + QString path = + QFileDialog::getOpenFileName(this, tr("Load Actor Group"), QString(), + tr("OpenMix Actor Group (*.omgroup);;All Files (*)")); + if (path.isEmpty()) + return; + + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + QMessageBox::warning(this, tr("Load Actor Group"), + tr("Failed to read file: %1").arg(file.errorString())); + return; + } + QJsonParseError err; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &err); + if (err.error != QJsonParseError::NoError || !doc.isObject() || + !ActorGroupIo::isActorGroup(doc.object())) { + QMessageBox::warning(this, tr("Load Actor Group"), tr("Not a valid actor-group file.")); + return; + } + + // merge the group's slots, then append its actors (fresh ids avoid collisions) + for (const QString& slot : ActorGroupIo::slotsFromJson(doc.object())) + m_library->addSlot(slot); + + const QList actors = ActorGroupIo::actorsFromJson(doc.object(), /*regenerateIds=*/true); + int order = 0; + for (const Actor& a : m_library->actors()) + order = std::max(order, a.order()); + + QString firstId; + for (Actor a : actors) { + a.setOrder(++order); + if (firstId.isEmpty()) + firstId = a.id(); + m_library->addActor(a); + } + rebuildActorTree(firstId); +} + +void ActorSetupPanel::onLibraryChanged() { + // external change (e.g. a project was loaded); ignore our own edits which are + // guarded by m_updatingUi to avoid clobbering in-progress editing. + if (m_updatingUi) + return; + refresh(); +} + +} // namespace OpenMix diff --git a/src/ui/ActorSetupPanel.h b/src/ui/ActorSetupPanel.h new file mode 100644 index 0000000..891227c --- /dev/null +++ b/src/ui/ActorSetupPanel.h @@ -0,0 +1,121 @@ +#pragma once + +#include +#include + +class QCheckBox; +class QComboBox; +class QLabel; +class QLineEdit; +class QPushButton; +class QSpinBox; +class QTabWidget; +class QTreeWidget; + +namespace OpenMix { + +class Application; +class Actor; +class ActorProfileLibrary; +class VoiceEditorWidget; + +// Cast-management panel mirroring TheatreMix's Actor Setup dialog: an actor tree +// with add/remove/move controls, a per-actor voice editor (gain/HPF/EQ/dynamics) +// for each profile slot plus the spare-mic backup voice, profile-slot management, +// a backup-channel toggle, copy/paste/import/export of actor values, and +// load/save of actor groups. Edits flow straight into the Show's +// ActorProfileLibrary (which marks the show modified and persists in the project). +class ActorSetupPanel : public QWidget { + Q_OBJECT + + public: + explicit ActorSetupPanel(Application* app, QWidget* parent = nullptr); + + public slots: + void refresh(); + + private slots: + // actor list + void onActorSelectionChanged(); + void addActor(); + void removeActor(); + void moveActorUp(); + void moveActorDown(); + + // per-actor fields + void onNameChanged(const QString& text); + void onChannelChanged(int channel); + void onActiveToggled(bool on); + void onBackupToggled(bool on); + + // profile slots + void onSlotChanged(int index); + void addSlot(); + void removeSlot(); + + // import/export + void copyActorValues(); + void pasteActorValues(); + void importActorValues(); + void exportActorValues(); + void saveActorGroup(); + void loadActorGroup(); + + void onLibraryChanged(); + + private: + void setupUi(); + void loadDisplayOptions(); + void rebuildActorTree(const QString& selectId = QString()); + void rebuildSlotCombo(); + void loadActorIntoEditor(); + void commitVoiceEdits(); + void setEditorEnabled(bool on); + void updateButtonStates(); + + [[nodiscard]] QString selectedActorId() const; + [[nodiscard]] int channelCount() const; + + Application* m_app; + ActorProfileLibrary* m_library = nullptr; + + bool m_updatingUi = false; + QString m_currentSlot; + QJsonObject m_copiedProfiles; // in-app clipboard for copy/paste actor values + + // actor list + QTreeWidget* m_actorTree = nullptr; + QPushButton* m_addActorBtn = nullptr; + QPushButton* m_removeActorBtn = nullptr; + QPushButton* m_moveUpBtn = nullptr; + QPushButton* m_moveDownBtn = nullptr; + + // toolbar + QPushButton* m_copyBtn = nullptr; + QPushButton* m_pasteBtn = nullptr; + QPushButton* m_importBtn = nullptr; + QPushButton* m_exportBtn = nullptr; + QPushButton* m_saveGroupBtn = nullptr; + QPushButton* m_loadGroupBtn = nullptr; + + // editor + QWidget* m_editor = nullptr; + QLineEdit* m_nameEdit = nullptr; + QSpinBox* m_channelSpin = nullptr; + QCheckBox* m_activeCheck = nullptr; + QCheckBox* m_backupCheck = nullptr; + + QComboBox* m_slotCombo = nullptr; + QPushButton* m_addSlotBtn = nullptr; + QPushButton* m_removeSlotBtn = nullptr; + + QTabWidget* m_voiceTabs = nullptr; + VoiceEditorWidget* m_mainVoice = nullptr; + VoiceEditorWidget* m_backupVoice = nullptr; + + // display options (persisted to QSettings; consumed by check-cue rendering) + QCheckBox* m_scribbleNamesCheck = nullptr; + QCheckBox* m_cueZeroLabelsCheck = nullptr; +}; + +} // namespace OpenMix diff --git a/src/ui/CueConfidenceIndicator.cpp b/src/ui/CueConfidenceIndicator.cpp index 65ffd7f..f4b0f62 100644 --- a/src/ui/CueConfidenceIndicator.cpp +++ b/src/ui/CueConfidenceIndicator.cpp @@ -56,6 +56,14 @@ void CueConfidenceIndicator::validate() { emit validationCompleted(m_level); } +void CueConfidenceIndicator::setConfidence(ConfidenceLevel level, const QString& tooltip) { + m_level = level; + m_tooltipText = tooltip; + setToolTip(tooltip); + update(); + emit validationCompleted(m_level); +} + QString CueConfidenceIndicator::tooltipText() const { return m_tooltipText; } QSize CueConfidenceIndicator::sizeHint() const { return QSize(16, 16); } diff --git a/src/ui/CueConfidenceIndicator.h b/src/ui/CueConfidenceIndicator.h index ba8e380..766071e 100644 --- a/src/ui/CueConfidenceIndicator.h +++ b/src/ui/CueConfidenceIndicator.h @@ -36,6 +36,10 @@ class CueConfidenceIndicator : public QWidget { // manually trigger validation void validate(); + // directly set a confidence state (e.g. post-fire landed/drift feedback from + // the playback engine), bypassing the validator + void setConfidence(ConfidenceLevel level, const QString& tooltip); + // get tooltip text describing issues QString tooltipText() const; diff --git a/src/ui/CueEditor.cpp b/src/ui/CueEditor.cpp index 14cb126..74d464a 100644 --- a/src/ui/CueEditor.cpp +++ b/src/ui/CueEditor.cpp @@ -1,9 +1,13 @@ #include "CueEditor.h" #include "app/Application.h" +#include "core/Actor.h" +#include "core/ActorProfileLibrary.h" #include "core/Cue.h" #include "core/CueList.h" +#include "core/FadeCurve.h" #include "core/PlaybackEngine.h" #include "core/Show.h" +#include "theme/Theme.h" #include #include @@ -12,18 +16,31 @@ #include #include #include +#include #include #include #include #include +#include +#include #include #include +#include + namespace OpenMix { CueEditor::CueEditor(Application* app, QWidget* parent) : QWidget(parent), m_app(app) { + if (m_app && m_app->show()) + m_actorLibrary = m_app->show()->actorProfileLibrary(); + setupUi(); setEnabled(false); + + if (m_actorLibrary) { + connect(m_actorLibrary, &ActorProfileLibrary::changed, this, + &CueEditor::onActorLibraryChanged); + } } void CueEditor::setupUi() { @@ -72,6 +89,27 @@ void CueEditor::setupUi() { m_mainLayout->addWidget(timingGroup); + // fade transition group + QGroupBox* fadeGroup = new QGroupBox(tr("Fade Transition"), this); + QFormLayout* fadeLayout = new QFormLayout(fadeGroup); + + m_fadeTimeSpin = new QDoubleSpinBox(this); + m_fadeTimeSpin->setRange(0.0, 600.0); + m_fadeTimeSpin->setDecimals(2); + m_fadeTimeSpin->setSingleStep(0.5); + m_fadeTimeSpin->setSuffix(tr(" sec")); + m_fadeTimeSpin->setToolTip(tr("Fade duration for fader/level moves on fire (0 = instant)")); + fadeLayout->addRow(tr("Fade time:"), m_fadeTimeSpin); + + m_fadeCurveCombo = new QComboBox(this); + m_fadeCurveCombo->addItem(tr("Linear"), static_cast(FadeCurve::Linear)); + m_fadeCurveCombo->addItem(tr("Ease In-Out"), static_cast(FadeCurve::EaseInOut)); + m_fadeCurveCombo->addItem(tr("Ease In"), static_cast(FadeCurve::EaseIn)); + m_fadeCurveCombo->addItem(tr("Ease Out"), static_cast(FadeCurve::EaseOut)); + fadeLayout->addRow(tr("Curve:"), m_fadeCurveCombo); + + m_mainLayout->addWidget(fadeGroup); + // DCA targeting section createDCATargetingSection(); m_mainLayout->addWidget(m_dcaTargetingGroup); @@ -139,6 +177,19 @@ void CueEditor::setupUi() { m_mainLayout->addWidget(m_dcaOverridesGroup); + // per-channel actor profile + level + createChannelProfilesSection(); + m_mainLayout->addWidget(m_channelProfilesGroup); + + // linked QLab (DAW remote) cue + QGroupBox* qlabGroup = new QGroupBox(tr("QLab / DAW Remote"), this); + QFormLayout* qlabLayout = new QFormLayout(qlabGroup); + m_qLabCueEdit = new QLineEdit(this); + m_qLabCueEdit->setPlaceholderText(tr("QLab cue number / id")); + m_qLabCueEdit->setToolTip(tr("Fire this QLab cue when the OpenMix cue executes")); + qlabLayout->addRow(tr("QLab cue:"), m_qLabCueEdit); + m_mainLayout->addWidget(qlabGroup); + // notes group QGroupBox* notesGroup = new QGroupBox(tr("Notes"), this); QVBoxLayout* notesLayout = new QVBoxLayout(notesGroup); @@ -162,6 +213,35 @@ void CueEditor::setupUi() { connect(m_autoFollowDelaySpin, QOverload::of(&QDoubleSpinBox::valueChanged), this, &CueEditor::onAutoFollowDelayChanged); connect(m_notesEdit, &QTextEdit::textChanged, this, &CueEditor::onNotesChanged); + + connect(m_fadeTimeSpin, QOverload::of(&QDoubleSpinBox::valueChanged), this, + &CueEditor::onFadeTimeChanged); + connect(m_fadeCurveCombo, QOverload::of(&QComboBox::currentIndexChanged), this, + &CueEditor::onFadeCurveChanged); + connect(m_qLabCueEdit, &QLineEdit::textChanged, this, &CueEditor::onQLabCueChanged); +} + +void CueEditor::createChannelProfilesSection() { + m_channelProfilesGroup = new QGroupBox(tr("Channel Profiles & Levels"), this); + QVBoxLayout* layout = new QVBoxLayout(m_channelProfilesGroup); + layout->setContentsMargins(4, 4, 4, 4); + + QLabel* hint = new QLabel( + tr("Per actor-channel: the profile slot applied on fire, and an optional fader level."), + m_channelProfilesGroup); + hint->setWordWrap(true); + hint->setStyleSheet(QString("color: %1;").arg(Theme::Colors::TextSecondary)); + layout->addWidget(hint); + + m_channelTable = new QTableWidget(0, 5, m_channelProfilesGroup); + m_channelTable->setHorizontalHeaderLabels( + {tr("Ch"), tr("Actor"), tr("Profile"), tr("Set"), tr("Level")}); + m_channelTable->verticalHeader()->setVisible(false); + m_channelTable->setSelectionMode(QAbstractItemView::NoSelection); + m_channelTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_channelTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + m_channelTable->setMaximumHeight(220); + layout->addWidget(m_channelTable); } void CueEditor::addBottomWidget(QWidget* widget) { @@ -283,6 +363,18 @@ void CueEditor::updateFromCue() { // DCA overrides updateDCAOverridesUI(); + + // fade transition + m_fadeTimeSpin->setValue(cue->fadeTime()); + int curveIdx = m_fadeCurveCombo->findData(static_cast(cue->fadeCurve())); + m_fadeCurveCombo->setCurrentIndex(curveIdx < 0 ? 0 : curveIdx); + + // linked QLab cue + m_qLabCueEdit->setText(cue->qLabCue()); + + // per-channel profile + level + rebuildChannelTable(); + populateChannelTable(); } else { m_numberSpin->setValue(0); m_nameEdit->clear(); @@ -294,6 +386,12 @@ void CueEditor::updateFromCue() { for (QCheckBox* cb : m_dcaTargetChecks) { cb->setChecked(false); } + + m_fadeTimeSpin->setValue(0.0); + m_fadeCurveCombo->setCurrentIndex(0); + m_qLabCueEdit->clear(); + if (m_channelTable) + m_channelTable->setRowCount(0); } m_updatingUi = false; @@ -330,6 +428,10 @@ void CueEditor::setEnabled(bool enabled) { m_notesEdit->setEnabled(enabled); m_dcaTargetingGroup->setEnabled(enabled); m_dcaOverridesGroup->setEnabled(enabled); + m_fadeTimeSpin->setEnabled(enabled); + m_fadeCurveCombo->setEnabled(enabled); + m_qLabCueEdit->setEnabled(enabled); + m_channelProfilesGroup->setEnabled(enabled); } void CueEditor::onNumberChanged(double value) { @@ -462,4 +564,212 @@ void CueEditor::onDCAOverrideChanged(int dca) { emit cueModified(); } +void CueEditor::rebuildChannelTable() { + if (!m_channelTable) + return; + m_channelTable->setRowCount(0); + if (!m_actorLibrary) + return; + + // one row per actor-assigned channel (deduped, lowest channel first) + QList actors = m_actorLibrary->actors(); + std::stable_sort(actors.begin(), actors.end(), + [](const Actor& a, const Actor& b) { return a.channel() < b.channel(); }); + + const QStringList slotList = m_actorLibrary->profileSlots(); + QSet seen; + + for (const Actor& a : actors) { + const int ch = a.channel(); + if (seen.contains(ch)) + continue; + seen.insert(ch); + + const int row = m_channelTable->rowCount(); + m_channelTable->insertRow(row); + + auto* chItem = new QTableWidgetItem(QString::number(ch)); + chItem->setFlags(Qt::ItemIsEnabled); + m_channelTable->setItem(row, 0, chItem); + + auto* nameItem = new QTableWidgetItem(a.name().isEmpty() ? tr("(unnamed)") : a.name()); + nameItem->setFlags(Qt::ItemIsEnabled); + m_channelTable->setItem(row, 1, nameItem); + + auto* profileCombo = new QComboBox(m_channelTable); + profileCombo->addItem(tr("(none)"), QString()); + for (const QString& s : slotList) + profileCombo->addItem(s, s); + profileCombo->setProperty("channel", ch); + connect(profileCombo, QOverload::of(&QComboBox::currentIndexChanged), this, + [this, ch]() { onChannelProfileChanged(ch); }); + m_channelTable->setCellWidget(row, 2, profileCombo); + + auto* setCheck = new QCheckBox(m_channelTable); + setCheck->setToolTip(tr("Override this channel's fader level on fire")); + setCheck->setProperty("channel", ch); + connect(setCheck, &QCheckBox::toggled, this, + [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(" %")); + levelSpin->setEnabled(false); + levelSpin->setProperty("channel", ch); + connect(levelSpin, QOverload::of(&QSpinBox::valueChanged), this, + [this, ch]() { onChannelLevelChanged(ch); }); + m_channelTable->setCellWidget(row, 4, levelSpin); + } +} + +void CueEditor::populateChannelTable() { + Cue* cue = currentCue(); + if (!cue || !m_channelTable) + return; + + const QMap profiles = cue->channelProfiles(); + const QMap levels = cue->channelLevels(); + + 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)); + if (!combo || !check || !spin) + continue; + const int ch = combo->property("channel").toInt(); + + const int idx = combo->findData(profiles.value(ch)); + combo->setCurrentIndex(idx < 0 ? 0 : idx); + + 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); + } +} + +void CueEditor::onFadeTimeChanged(double value) { + if (m_updatingUi) + return; + Cue* cue = currentCue(); + if (!cue) + return; + cue->setFadeTime(value); + m_app->show()->cueList()->updateCue(m_currentIndex, *cue); + emit cueModified(); +} + +void CueEditor::onFadeCurveChanged(int index) { + if (m_updatingUi) + return; + Cue* cue = currentCue(); + if (!cue) + return; + cue->setFadeCurve(static_cast(m_fadeCurveCombo->itemData(index).toInt())); + m_app->show()->cueList()->updateCue(m_currentIndex, *cue); + emit cueModified(); +} + +void CueEditor::onQLabCueChanged(const QString& text) { + if (m_updatingUi) + return; + Cue* cue = currentCue(); + if (!cue) + return; + cue->setQLabCue(text); + m_app->show()->cueList()->updateCue(m_currentIndex, *cue); + emit cueModified(); +} + +void CueEditor::onChannelProfileChanged(int channel) { + if (m_updatingUi) + return; + Cue* cue = currentCue(); + if (!cue || !m_channelTable) + return; + + QComboBox* combo = nullptr; + for (int row = 0; row < m_channelTable->rowCount(); ++row) { + auto* c = qobject_cast(m_channelTable->cellWidget(row, 2)); + if (c && c->property("channel").toInt() == channel) { + combo = c; + break; + } + } + if (!combo) + return; + + const QString slot = combo->currentData().toString(); + if (slot.isEmpty()) + cue->removeChannelProfile(channel); + else + cue->setChannelProfile(channel, slot); + + m_app->show()->cueList()->updateCue(m_currentIndex, *cue); + emit cueModified(); +} + +void CueEditor::onChannelLevelToggled(int channel, bool on) { + if (m_updatingUi) + return; + Cue* cue = currentCue(); + if (!cue || !m_channelTable) + return; + + QSpinBox* spin = nullptr; + for (int row = 0; row < m_channelTable->rowCount(); ++row) { + auto* s = qobject_cast(m_channelTable->cellWidget(row, 4)); + if (s && s->property("channel").toInt() == channel) { + spin = s; + break; + } + } + if (spin) + spin->setEnabled(on); + + if (on) + cue->setChannelLevel(channel, (spin ? spin->value() : 75) / 100.0); + else + cue->removeChannelLevel(channel); + + m_app->show()->cueList()->updateCue(m_currentIndex, *cue); + emit cueModified(); +} + +void CueEditor::onChannelLevelChanged(int channel) { + if (m_updatingUi) + return; + Cue* cue = currentCue(); + if (!cue || !m_channelTable) + return; + + QSpinBox* spin = nullptr; + for (int row = 0; row < m_channelTable->rowCount(); ++row) { + auto* s = qobject_cast(m_channelTable->cellWidget(row, 4)); + if (s && s->property("channel").toInt() == channel) { + spin = s; + break; + } + } + if (!spin || !spin->isEnabled()) + return; + + cue->setChannelLevel(channel, spin->value() / 100.0); + m_app->show()->cueList()->updateCue(m_currentIndex, *cue); + emit cueModified(); +} + +void CueEditor::onActorLibraryChanged() { + // actors/slots changed (e.g. project loaded, or edited in Actor Setup): + // refresh the per-channel table for the cue currently shown. + if (m_currentIndex < 0) + return; + m_updatingUi = true; + rebuildChannelTable(); + populateChannelTable(); + m_updatingUi = false; +} + } // namespace OpenMix diff --git a/src/ui/CueEditor.h b/src/ui/CueEditor.h index a5a3095..d4a0cc9 100644 --- a/src/ui/CueEditor.h +++ b/src/ui/CueEditor.h @@ -14,10 +14,12 @@ class QListWidget; class QGroupBox; class QScrollArea; class QVBoxLayout; +class QTableWidget; namespace OpenMix { class Application; +class ActorProfileLibrary; class Cue; class CueEditor : public QWidget { @@ -46,9 +48,21 @@ class CueEditor : public QWidget { void onTargetedDCAsChanged(); void onDCAOverrideChanged(int dca); + void onFadeTimeChanged(double value); + void onFadeCurveChanged(int index); + void onQLabCueChanged(const QString& text); + void onChannelProfileChanged(int channel); + void onChannelLevelToggled(int channel, bool on); + void onChannelLevelChanged(int channel); + void onActorLibraryChanged(); + private: void setupUi(); void createDCATargetingSection(); + void createFadeSection(); + void createChannelProfilesSection(); + void rebuildChannelTable(); + void populateChannelTable(); void updateFromCue(); void updateDCAOverridesUI(); void setEnabled(bool enabled); @@ -68,6 +82,18 @@ class CueEditor : public QWidget { QDoubleSpinBox* m_autoFollowDelaySpin; QTextEdit* m_notesEdit; + // fade transition + QDoubleSpinBox* m_fadeTimeSpin = nullptr; + QComboBox* m_fadeCurveCombo = nullptr; + + // linked QLab (DAW remote) cue id + QLineEdit* m_qLabCueEdit = nullptr; + + // per-channel actor profile slot + level + ActorProfileLibrary* m_actorLibrary = nullptr; + QGroupBox* m_channelProfilesGroup = nullptr; + QTableWidget* m_channelTable = nullptr; + // DCA targeting widgets QGroupBox* m_dcaTargetingGroup; QCheckBox* m_targetAllDCAsCheck; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 33e3f00..fb657ff 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -1,4 +1,5 @@ #include "MainWindow.h" +#include "ActorSetupPanel.h" #include "BubbleBar.h" #include "BubbleButton.h" #include "ConnectionPanel.h" @@ -9,6 +10,7 @@ #include "LogViewerDialog.h" #include "MixerFeedbackPanel.h" #include "PopOutWindow.h" +#include "RemoteControlDialog.h" #include "app/Application.h" #include "core/AppLogger.h" #include "core/CueList.h" @@ -188,6 +190,13 @@ void MainWindow::createActions() { m_showConnectionAction->setToolTip(tr("Show/hide connection panel (F7)")); connect(m_showConnectionAction, &QAction::triggered, this, &MainWindow::toggleConnectionPanel); + m_showActorSetupAction = new QAction(Icons::actorSetup(), tr("&Actor Setup"), this); + m_showActorSetupAction->setCheckable(true); + m_showActorSetupAction->setChecked(false); + m_showActorSetupAction->setShortcut(Qt::Key_F9); + m_showActorSetupAction->setToolTip(tr("Show/hide actor setup panel (F9)")); + connect(m_showActorSetupAction, &QAction::triggered, this, &MainWindow::toggleActorSetupPanel); + m_showLogViewerAction = new QAction(tr("Application &Log..."), this); m_showLogViewerAction->setShortcut(Qt::Key_F8); m_showLogViewerAction->setToolTip(tr("Show application log (F8)")); @@ -203,6 +212,10 @@ void MainWindow::createActions() { m_midiControllerAction->setToolTip(tr("Configure MIDI controller mappings")); connect(m_midiControllerAction, &QAction::triggered, this, &MainWindow::showMidiConfigDialog); + m_remoteControlAction = new QAction(Icons::remoteControl(), tr("Remote Control..."), this); + m_remoteControlAction->setToolTip(tr("Configure MSC, inbound OSC, and QLab remote control")); + connect(m_remoteControlAction, &QAction::triggered, this, &MainWindow::showRemoteControlDialog); + // help actions m_aboutAction = new QAction(tr("&About OpenMix"), this); m_aboutAction->setToolTip(tr("About this application")); @@ -248,11 +261,13 @@ void MainWindow::registerShortcuts() { sm->registerAction("view.dcaMapping", m_showDCAMappingAction, QKeySequence(Qt::Key_F5)); sm->registerAction("view.mixerFeedback", m_showMixerFeedbackAction, QKeySequence(Qt::Key_F6)); sm->registerAction("view.connection", m_showConnectionAction, QKeySequence(Qt::Key_F7)); + sm->registerAction("view.actorSetup", m_showActorSetupAction, QKeySequence(Qt::Key_F9)); sm->registerAction("view.logViewer", m_showLogViewerAction, QKeySequence(Qt::Key_F8)); // settings actions sm->registerAction("settings.keyboardShortcuts", m_keyboardShortcutsAction, QKeySequence()); sm->registerAction("settings.midiController", m_midiControllerAction, QKeySequence()); + sm->registerAction("settings.remoteControl", m_remoteControlAction, QKeySequence()); // help actions sm->registerAction("help.about", m_aboutAction, QKeySequence()); @@ -293,12 +308,14 @@ void MainWindow::createMenus() { m_viewMenu->addAction(m_showDCAMappingAction); m_viewMenu->addAction(m_showMixerFeedbackAction); m_viewMenu->addAction(m_showConnectionAction); + m_viewMenu->addAction(m_showActorSetupAction); m_viewMenu->addSeparator(); m_viewMenu->addAction(m_showLogViewerAction); m_settingsMenu = menuBar()->addMenu(tr("&Settings")); m_settingsMenu->addAction(m_keyboardShortcutsAction); m_settingsMenu->addAction(m_midiControllerAction); + m_settingsMenu->addAction(m_remoteControlAction); m_helpMenu = menuBar()->addMenu(tr("&Help")); m_helpMenu->addAction(m_aboutAction); @@ -367,6 +384,16 @@ void MainWindow::createPopOutWindows() { m_showDCAMappingAction->setChecked(visible); m_bubbleBar->setButtonActive("dcaMapping", visible); }); + + m_actorSetupPanel = new ActorSetupPanel(m_app, nullptr); + m_actorSetupPopOut = new PopOutWindow("actorSetup", tr("Actor Setup"), this); + m_actorSetupPopOut->setContentWidget(m_actorSetupPanel); + m_actorSetupPopOut->setMinimumContentSize(760, 520); + + connect(m_actorSetupPopOut, &PopOutWindow::visibilityChanged, [this](bool visible) { + m_showActorSetupAction->setChecked(visible); + m_bubbleBar->setButtonActive("actors", visible); + }); } void MainWindow::createBubbleBar() { @@ -375,6 +402,7 @@ void MainWindow::createBubbleBar() { m_bubbleBar->addButton("dcaMapping", Icons::sliders(), tr("DCA Mapping (F5)")); m_bubbleBar->addButton("mixer", Icons::audioVolume(), tr("Mixer Feedback (F6)")); m_bubbleBar->addButton("connection", Icons::network(), tr("Connection (F7)")); + m_bubbleBar->addButton("actors", Icons::actorSetup(), tr("Actor Setup (F9)")); connect(m_bubbleBar, &BubbleBar::buttonClicked, this, &MainWindow::onBubbleButtonClicked); @@ -389,6 +417,8 @@ void MainWindow::onBubbleButtonClicked(const QString& id, [[maybe_unused]] bool toggleMixerFeedbackPanel(); } else if (id == "connection") { toggleConnectionPanel(); + } else if (id == "actors") { + toggleActorSetupPanel(); } } @@ -408,6 +438,19 @@ void MainWindow::connectSignals() { connect(m_app->playbackEngine(), &PlaybackEngine::currentCueChanged, m_mixerFeedbackPanel, &MixerFeedbackPanel::onActiveCueChanged); + // post-fire confidence: surface cue landed/drift from verification + connect(m_app->playbackEngine(), &PlaybackEngine::cueLanded, m_mixerFeedbackPanel, + &MixerFeedbackPanel::onCueLanded); + connect(m_app->playbackEngine(), &PlaybackEngine::cueDrifted, m_mixerFeedbackPanel, + &MixerFeedbackPanel::onCueDrifted); + connect(m_app->playbackEngine(), &PlaybackEngine::cueLanded, this, + [this](int) { statusBar()->showMessage(tr("Cue landed - console matches"), 2500); }); + connect(m_app->playbackEngine(), &PlaybackEngine::cueDrifted, this, + [this](int, const QStringList& paths) { + statusBar()->showMessage( + tr("Cue drift on %n parameter(s) - see Mixer Feedback", "", paths.size()), 4000); + }); + connect(m_app->playbackEngine(), &PlaybackEngine::standbyCueChanged, this, &MainWindow::updateStatusBar); @@ -647,6 +690,15 @@ void MainWindow::toggleDCAMappingPanel() { } } +void MainWindow::toggleActorSetupPanel() { + if (m_actorSetupPopOut->isVisible()) { + m_actorSetupPopOut->hide(); + } else { + m_actorSetupPopOut->showAndRestore(); + m_actorSetupPanel->refresh(); + } +} + void MainWindow::updateTitle() { QString title = m_app->show()->name(); if (m_app->show()->isModified()) { @@ -807,6 +859,11 @@ void MainWindow::showMidiConfigDialog() { dialog.exec(); } +void MainWindow::showRemoteControlDialog() { + RemoteControlDialog dialog(m_app, this); + dialog.exec(); +} + void MainWindow::showKeyboardShortcutsDialog() { KeyboardShortcutsDialog dialog(m_app->shortcutManager(), this); dialog.exec(); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 7f38862..519eab7 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -17,6 +17,7 @@ class CueEditor; class ConnectionPanel; class MixerFeedbackPanel; class DCAMappingPanel; +class ActorSetupPanel; class PopOutWindow; class BubbleBar; class PlaybackGuard; @@ -56,6 +57,7 @@ class MainWindow : public QMainWindow { void toggleConnectionPanel(); void toggleMixerFeedbackPanel(); void toggleDCAMappingPanel(); + void toggleActorSetupPanel(); // update UI state void updateTitle(); @@ -75,6 +77,7 @@ class MainWindow : public QMainWindow { // settings dialogs void showMidiConfigDialog(); + void showRemoteControlDialog(); void showKeyboardShortcutsDialog(); void showLogViewerDialog(); @@ -107,11 +110,13 @@ class MainWindow : public QMainWindow { ConnectionPanel* m_connectionPanel; MixerFeedbackPanel* m_mixerFeedbackPanel; DCAMappingPanel* m_dcaMappingPanel; + ActorSetupPanel* m_actorSetupPanel; // pop-out windows PopOutWindow* m_connectionPopOut; PopOutWindow* m_mixerFeedbackPopOut; PopOutWindow* m_dcaMappingPopOut; + PopOutWindow* m_actorSetupPopOut; // bubble bar BubbleBar* m_bubbleBar; @@ -157,11 +162,13 @@ class MainWindow : public QMainWindow { QAction* m_showConnectionAction; QAction* m_showMixerFeedbackAction; QAction* m_showDCAMappingAction; + QAction* m_showActorSetupAction; QAction* m_showLogViewerAction; // settings actions QAction* m_keyboardShortcutsAction; QAction* m_midiControllerAction; + QAction* m_remoteControlAction; // help actions QAction* m_aboutAction; diff --git a/src/ui/MixerFeedbackPanel.cpp b/src/ui/MixerFeedbackPanel.cpp index 0b91ccb..3dfd8a9 100644 --- a/src/ui/MixerFeedbackPanel.cpp +++ b/src/ui/MixerFeedbackPanel.cpp @@ -1,4 +1,5 @@ #include "MixerFeedbackPanel.h" +#include "CueConfidenceIndicator.h" #include "DCAWidget.h" #include "app/Application.h" #include "core/Cue.h" @@ -6,12 +7,14 @@ #include "core/PlaybackEngine.h" #include "core/Show.h" #include "protocol/MixerProtocol.h" +#include "theme/Theme.h" #include #include #include #include #include #include +#include #include namespace OpenMix { @@ -36,19 +39,38 @@ void MixerFeedbackPanel::setupUi() { setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); setFocusPolicy(Qt::StrongFocus); - QHBoxLayout* layout = new QHBoxLayout(this); - layout->setContentsMargins(8, 8, 8, 8); - layout->setSpacing(4); + QVBoxLayout* outer = new QVBoxLayout(this); + outer->setContentsMargins(8, 8, 8, 8); + outer->setSpacing(6); + + // post-fire confidence row: did the last GO actually land on the console? + QHBoxLayout* statusRow = new QHBoxLayout(); + statusRow->setSpacing(6); + QLabel* caption = new QLabel(tr("Last fire:"), this); + m_fireIndicator = new CueConfidenceIndicator(this); + m_fireIndicator->setFixedSize(14, 14); + m_fireIndicator->setConfidence(ConfidenceLevel::Unknown, tr("Awaiting GO")); + m_fireStatusLabel = new QLabel(tr("Awaiting GO"), this); + m_fireStatusLabel->setStyleSheet(QString("color: %1;").arg(Theme::Colors::TextSecondary)); + statusRow->addWidget(caption); + statusRow->addWidget(m_fireIndicator); + statusRow->addWidget(m_fireStatusLabel, 1); + statusRow->addStretch(); + outer->addLayout(statusRow); + + m_dcaLayout = new QHBoxLayout(); + m_dcaLayout->setSpacing(4); // create default 8 DCA widgets (will be adjusted by setDCACount) for (int i = 1; i <= 8; ++i) { DCAWidget* dca = new DCAWidget(i, this); connectDCASignals(dca); m_dcaWidgets.append(dca); - layout->addWidget(dca); + m_dcaLayout->addWidget(dca); } - layout->addStretch(); + m_dcaLayout->addStretch(); + outer->addLayout(m_dcaLayout); } void MixerFeedbackPanel::connectDCASignals(DCAWidget* dca) { @@ -60,13 +82,12 @@ void MixerFeedbackPanel::connectDCASignals(DCAWidget* dca) { void MixerFeedbackPanel::setDCACount(int count) { count = std::clamp(count, 1, 24); // support up to 24 DCAs (WING max) - QHBoxLayout* layout = qobject_cast(this->layout()); - if (!layout) + if (!m_dcaLayout) return; while (m_dcaWidgets.size() > count) { DCAWidget* dca = m_dcaWidgets.takeLast(); - layout->removeWidget(dca); + m_dcaLayout->removeWidget(dca); dca->deleteLater(); } @@ -76,7 +97,7 @@ void MixerFeedbackPanel::setDCACount(int count) { connectDCASignals(dca); m_dcaWidgets.append(dca); // insert before stretch - layout->insertWidget(layout->count() - 1, dca); + m_dcaLayout->insertWidget(m_dcaLayout->count() - 1, dca); } } @@ -289,6 +310,40 @@ void MixerFeedbackPanel::clearCueSettings() { } } +QString MixerFeedbackPanel::cueDescription(int cueIndex) const { + if (!m_app || !m_app->show() || !m_app->show()->cueList()) + return tr("Cue %1").arg(cueIndex); + const CueList* cueList = m_app->show()->cueList(); + if (cueIndex < 0 || cueIndex >= cueList->count()) + return tr("Cue %1").arg(cueIndex); + const Cue& cue = cueList->at(cueIndex); + QString desc = tr("Cue %1").arg(cue.number(), 0, 'f', 1); + if (!cue.name().isEmpty()) + desc += QString(" - %1").arg(cue.name()); + return desc; +} + +void MixerFeedbackPanel::onCueLanded(int cueIndex) { + if (!m_fireIndicator) + return; + const QString desc = cueDescription(cueIndex); + m_fireIndicator->setConfidence(ConfidenceLevel::Good, + tr("%1 landed: console matches the fired values.").arg(desc)); + m_fireStatusLabel->setText(tr("%1 landed").arg(desc)); + m_fireStatusLabel->setStyleSheet(QString("color: %1;").arg(Theme::Colors::AccentGreen)); +} + +void MixerFeedbackPanel::onCueDrifted(int cueIndex, const QStringList& paths) { + if (!m_fireIndicator) + return; + const QString desc = cueDescription(cueIndex); + QString tooltip = tr("%1 drift on %n parameter(s):", "", paths.size()).arg(desc); + tooltip += "\n" + paths.join("\n"); + m_fireIndicator->setConfidence(ConfidenceLevel::Warning, tooltip); + m_fireStatusLabel->setText(tr("%1 drift on %n parameter(s)", "", paths.size()).arg(desc)); + m_fireStatusLabel->setStyleSheet(QString("color: %1;").arg(Theme::Colors::AccentAmber)); +} + void MixerFeedbackPanel::keyPressEvent(QKeyEvent* event) { if (event->key() == Qt::Key_Tab && !isAnyLabelBeingEdited()) { DCAWidget* first = firstEditableDCA(); diff --git a/src/ui/MixerFeedbackPanel.h b/src/ui/MixerFeedbackPanel.h index 36f8f9e..2a07f5e 100644 --- a/src/ui/MixerFeedbackPanel.h +++ b/src/ui/MixerFeedbackPanel.h @@ -4,11 +4,14 @@ #include class QKeyEvent; +class QHBoxLayout; +class QLabel; namespace OpenMix { class Application; class DCAWidget; +class CueConfidenceIndicator; class MixerFeedbackPanel : public QWidget { Q_OBJECT @@ -30,6 +33,10 @@ class MixerFeedbackPanel : public QWidget { void onActiveCueChanged(int cueIndex); + // post-fire confidence from PlaybackEngine cue verification + void onCueLanded(int cueIndex); + void onCueDrifted(int cueIndex, const QStringList& paths); + protected: void keyPressEvent(QKeyEvent* event) override; @@ -48,9 +55,16 @@ class MixerFeedbackPanel : public QWidget { void loadCueSettings(const QString& cueId); void clearCueSettings(); + QString cueDescription(int cueIndex) const; + Application* m_app; + QHBoxLayout* m_dcaLayout = nullptr; QVector m_dcaWidgets; QString m_activeCueId; + + // post-fire ("did the cue land?") status row + CueConfidenceIndicator* m_fireIndicator = nullptr; + QLabel* m_fireStatusLabel = nullptr; }; } // namespace OpenMix diff --git a/src/ui/RemoteControlDialog.cpp b/src/ui/RemoteControlDialog.cpp new file mode 100644 index 0000000..fc42443 --- /dev/null +++ b/src/ui/RemoteControlDialog.cpp @@ -0,0 +1,145 @@ +#include "RemoteControlDialog.h" +#include "app/Application.h" +#include "app/OscRemoteServer.h" +#include "app/QLabClient.h" +#include "midi/MidiInputManager.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace OpenMix { + +RemoteControlDialog::RemoteControlDialog(Application* app, QWidget* parent) + : QDialog(parent), m_app(app) { + setWindowTitle(tr("Remote Control")); + setupUi(); + loadValues(); +} + +void RemoteControlDialog::setupUi() { + auto* layout = new QVBoxLayout(this); + + // --- MIDI Show Control ------------------------------------------------- + auto* mscBox = new QGroupBox(tr("MIDI Show Control (MSC)"), this); + auto* mscForm = new QFormLayout(mscBox); + m_mscEnabled = new QCheckBox(tr("Respond to incoming MSC GO/STOP"), mscBox); + m_mscDeviceId = new QSpinBox(mscBox); + m_mscDeviceId->setRange(0, 127); + m_mscDeviceId->setToolTip(tr("MSC device id to match; 127 responds to all devices")); + mscForm->addRow(m_mscEnabled); + mscForm->addRow(tr("Device id:"), m_mscDeviceId); + auto* mscNote = new QLabel( + tr("MSC rides on the MIDI input device set in Settings > MIDI Controller."), mscBox); + mscNote->setWordWrap(true); + mscForm->addRow(mscNote); + layout->addWidget(mscBox); + + // --- inbound OSC ------------------------------------------------------- + auto* oscBox = new QGroupBox(tr("Inbound OSC Remote"), this); + auto* oscForm = new QFormLayout(oscBox); + m_oscEnabled = new QCheckBox(tr("Listen for OSC control"), oscBox); + m_oscPort = new QSpinBox(oscBox); + m_oscPort->setRange(1, 65535); + m_oscStatus = new QLabel(oscBox); + oscForm->addRow(m_oscEnabled); + oscForm->addRow(tr("Listen port:"), m_oscPort); + oscForm->addRow(tr("Status:"), m_oscStatus); + auto* oscNote = new QLabel( + tr("Accepts /go, /stop, /next, /prev, /cue/goto and /ctrl/fadeall."), oscBox); + oscNote->setWordWrap(true); + oscForm->addRow(oscNote); + layout->addWidget(oscBox); + + // --- outbound QLab ----------------------------------------------------- + auto* qlabBox = new QGroupBox(tr("QLab / DAW Remote (outbound)"), this); + auto* qlabForm = new QFormLayout(qlabBox); + m_qlabEnabled = new QCheckBox(tr("Fire linked QLab cues on GO"), qlabBox); + m_qlabHost = new QLineEdit(qlabBox); + m_qlabHost->setPlaceholderText(tr("127.0.0.1")); + m_qlabPort = new QSpinBox(qlabBox); + m_qlabPort->setRange(1, 65535); + m_qlabPreRoll = new QSpinBox(qlabBox); + m_qlabPreRoll->setRange(0, 60000); + m_qlabPreRoll->setSuffix(tr(" ms")); + m_qlabPreRoll->setSingleStep(50); + m_qlabWorkspace = new QLineEdit(qlabBox); + m_qlabWorkspace->setPlaceholderText(tr("(optional workspace id)")); + qlabForm->addRow(m_qlabEnabled); + qlabForm->addRow(tr("Host:"), m_qlabHost); + qlabForm->addRow(tr("Port:"), m_qlabPort); + qlabForm->addRow(tr("Pre-roll:"), m_qlabPreRoll); + qlabForm->addRow(tr("Workspace:"), m_qlabWorkspace); + layout->addWidget(qlabBox); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttons, &QDialogButtonBox::accepted, this, &RemoteControlDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &RemoteControlDialog::reject); + layout->addWidget(buttons); +} + +void RemoteControlDialog::loadValues() { + if (MidiInputManager* midi = m_app->midiInputManager()) { + m_mscEnabled->setChecked(midi->isMscEnabled()); + m_mscDeviceId->setValue(midi->mscDeviceId()); + } + + if (OscRemoteServer* osc = m_app->oscRemoteServer()) { + m_oscEnabled->setChecked(osc->isRunning()); + m_oscPort->setValue(osc->port() > 0 ? osc->port() : 8000); + m_oscStatus->setText(osc->isRunning() ? tr("Listening on port %1").arg(osc->port()) + : tr("Stopped")); + } + + if (QLabClient* qlab = m_app->qLabClient()) { + m_qlabEnabled->setChecked(qlab->isEnabled()); + m_qlabHost->setText(qlab->host()); + m_qlabPort->setValue(qlab->port()); + m_qlabPreRoll->setValue(qlab->preRollMs()); + m_qlabWorkspace->setText(qlab->workspaceId()); + } +} + +void RemoteControlDialog::applyValues() { + if (MidiInputManager* midi = m_app->midiInputManager()) { + midi->setMscEnabled(m_mscEnabled->isChecked()); + midi->setMscDeviceId(m_mscDeviceId->value()); + midi->saveToSettings(); + } + + if (OscRemoteServer* osc = m_app->oscRemoteServer()) { + // (re)bind to the chosen port when enabled, otherwise release it. The + // backend persists only the port, so the on/off choice is per-session. + if (m_oscEnabled->isChecked()) { + if (!osc->isRunning() || osc->port() != m_oscPort->value()) { + osc->stop(); + osc->start(m_oscPort->value()); + } + } else { + osc->stop(); + } + osc->saveToSettings(); + } + + if (QLabClient* qlab = m_app->qLabClient()) { + qlab->setEnabled(m_qlabEnabled->isChecked()); + qlab->setTarget(m_qlabHost->text().trimmed().isEmpty() ? QStringLiteral("127.0.0.1") + : m_qlabHost->text().trimmed(), + m_qlabPort->value()); + qlab->setPreRollMs(m_qlabPreRoll->value()); + qlab->setWorkspaceId(m_qlabWorkspace->text().trimmed()); + qlab->saveToSettings(); + } +} + +void RemoteControlDialog::accept() { + applyValues(); + QDialog::accept(); +} + +} // namespace OpenMix diff --git a/src/ui/RemoteControlDialog.h b/src/ui/RemoteControlDialog.h new file mode 100644 index 0000000..04279f9 --- /dev/null +++ b/src/ui/RemoteControlDialog.h @@ -0,0 +1,51 @@ +#pragma once + +#include + +class QCheckBox; +class QLabel; +class QLineEdit; +class QSpinBox; + +namespace OpenMix { + +class Application; + +// Settings dialog for the show-control surfaces wired up in Application: +// - MIDI Show Control (MSC) inbound GO/STOP from sysex (MidiInputManager) +// - inbound OSC remote control (OscRemoteServer) +// - outbound QLab / DAW remote (QLabClient) +// Drives the live service setters and persists via each service's saveToSettings. +class RemoteControlDialog : public QDialog { + Q_OBJECT + + public: + explicit RemoteControlDialog(Application* app, QWidget* parent = nullptr); + + void accept() override; + + private: + void setupUi(); + void loadValues(); + void applyValues(); + + Application* m_app; + + // MIDI Show Control + QCheckBox* m_mscEnabled = nullptr; + QSpinBox* m_mscDeviceId = nullptr; + + // inbound OSC + QCheckBox* m_oscEnabled = nullptr; + QSpinBox* m_oscPort = nullptr; + QLabel* m_oscStatus = nullptr; + + // outbound QLab + QCheckBox* m_qlabEnabled = nullptr; + QLineEdit* m_qlabHost = nullptr; + QSpinBox* m_qlabPort = nullptr; + QSpinBox* m_qlabPreRoll = nullptr; + QLineEdit* m_qlabWorkspace = nullptr; +}; + +} // namespace OpenMix diff --git a/src/ui/theme/Icons.h b/src/ui/theme/Icons.h index 4b502eb..9b091f2 100644 --- a/src/ui/theme/Icons.h +++ b/src/ui/theme/Icons.h @@ -61,4 +61,12 @@ inline QIcon download() { return fromPath(":/qlementine/icons/16/action/download inline QIcon upload() { return fromPath(":/qlementine/icons/16/document/open.svg"); } inline QIcon helpAbout() { return fromPath(":/qlementine/icons/16/misc/help.svg"); } +inline QIcon actorSetup() { return fromPath(":/qlementine/icons/16/misc/users.svg"); } +inline QIcon actor() { return fromPath(":/qlementine/icons/16/misc/user.svg"); } +inline QIcon remoteControl() { return fromPath(":/qlementine/icons/16/hardware/radio.svg"); } +inline QIcon copy() { return fromPath(":/qlementine/icons/16/action/copy.svg"); } +inline QIcon paste() { return fromPath(":/qlementine/icons/16/action/paste.svg"); } +inline QIcon moveUp() { return fromPath(":/qlementine/icons/16/navigation/chevron-up.svg"); } +inline QIcon moveDown() { return fromPath(":/qlementine/icons/16/navigation/chevron-down.svg"); } + } // namespace OpenMix::Icons From 96e6347df939fde193ecddf1e519ecc26a75dd97 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Tue, 30 Jun 2026 19:38:26 -0400 Subject: [PATCH 05/92] fix: X32 meter feed, exact WING fader law, build warning --- src/app/Application.cpp | 4 ++++ src/protocol/MixerProtocol.h | 4 ++++ src/protocol/behringer/WingProtocol.cpp | 18 ++++++++++----- src/protocol/behringer/X32Protocol.cpp | 29 +++++++++++++++++++++++++ src/protocol/behringer/X32Protocol.h | 1 + tests/test_cuezero.cpp | 4 ++-- 6 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/app/Application.cpp b/src/app/Application.cpp index 1b7d0be..7b758f4 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -213,6 +213,10 @@ void Application::setupMixerConnection(const QString& type, const QString& host, connect(m_mixer, &MixerProtocol::connected, this, [this]() { emit mixerConnected(); }); connect(m_mixer, &MixerProtocol::disconnected, this, [this]() { emit mixerDisconnected(); }); + // live console metering -> channel silence/clip monitor + connect(m_mixer, &MixerProtocol::channelMeter, m_channelMonitor, + [this](int channel, float level) { m_channelMonitor->onLevel(channel, level); }); + m_mixer->connect(host, port); QSettings settings; diff --git a/src/protocol/MixerProtocol.h b/src/protocol/MixerProtocol.h index 3523fba..8825854 100644 --- a/src/protocol/MixerProtocol.h +++ b/src/protocol/MixerProtocol.h @@ -74,6 +74,10 @@ class MixerProtocol : public QObject { void parameterChanged(const QString& path, const QVariant& value); void requestTimeout(const QString& path); + // live input-channel meter level (channel 1-based, level 0..1 linear). Emitted + // by drivers that subscribe to console metering; feeds the ChannelMonitor. + void channelMeter(int channel, float level); + void latencyChanged(int ms); void sceneChanged(int sceneNumber); }; diff --git a/src/protocol/behringer/WingProtocol.cpp b/src/protocol/behringer/WingProtocol.cpp index 606ede2..fb68538 100644 --- a/src/protocol/behringer/WingProtocol.cpp +++ b/src/protocol/behringer/WingProtocol.cpp @@ -228,15 +228,21 @@ void WingProtocol::recallScene(int sceneNumber) { namespace { QString wingChannel(int channel) { return QString("/ch/%1").arg(channel); } -// WING faders carry real-world dB; map a normalized 0..1 level onto the fader -// taper (raw 0.75 = 0 dB, 1.0 = +10 dB, 0.0 = -inf). Approximate. +// 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; - if (level >= 0.75) - return static_cast((level - 0.75) / 0.25 * 10.0); - return static_cast(level / 0.75 * 90.0 - 90.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); } // WING STD EQ exposes named bands l,1,2,3,4,h (not numbered 1..N) diff --git a/src/protocol/behringer/X32Protocol.cpp b/src/protocol/behringer/X32Protocol.cpp index 3c1b381..8d42217 100644 --- a/src/protocol/behringer/X32Protocol.cpp +++ b/src/protocol/behringer/X32Protocol.cpp @@ -1,9 +1,11 @@ #include "X32Protocol.h" #include "../../core/Cue.h" #include +#include #include #include #include +#include namespace OpenMix { @@ -360,6 +362,7 @@ void X32Protocol::onKeepAliveTimeout() { } m_transport.send("/xremote"); + m_transport.send(QString("/meters"), QString("/meters/1")); } } @@ -439,6 +442,11 @@ void X32Protocol::processResponse(const QString& path, const QVariant& value) { return; } + if (path == "/meters/1") { + parseMeters(value.toByteArray()); + return; + } + if (m_pendingRequests.contains(path)) { X32PendingRequest req = m_pendingRequests.take(path); qint64 roundTrip = now - req.sentTime; @@ -455,6 +463,25 @@ void X32Protocol::processResponse(const QString& path, const QVariant& value) { } } +void X32Protocol::parseMeters(const QByteArray& blob) { + // /meters/1 blob content: [uint32 LE count][count * float32 LE], each a + // linear input-channel level 0..1. Map the first N to channels 1..N. + if (blob.size() < 4) + return; + const auto* bytes = reinterpret_cast(blob.constData()); + const int count = static_cast(qFromLittleEndian(bytes)); + const int maxCh = std::min(count, m_capabilities.inputChannels); + for (int i = 0; i < maxCh; ++i) { + const int off = 4 + i * 4; + if (off + 4 > blob.size()) + break; + const quint32 raw = qFromLittleEndian(bytes + off); + float level = 0.0f; + std::memcpy(&level, &raw, sizeof(float)); + emit channelMeter(i + 1, level); + } +} + void X32Protocol::handleXinfoResponse([[maybe_unused]] const QVariant& value) { m_connectionTimer.stop(); m_waitingForXinfo = false; @@ -469,6 +496,8 @@ void X32Protocol::handleXinfoResponse([[maybe_unused]] const QVariant& value) { m_reconnectAttempts = 0; m_transport.send("/xremote"); + // subscribe to input-channel meters (bank 1); renewed on keep-alive + m_transport.send(QString("/meters"), QString("/meters/1")); emit connected(); } } diff --git a/src/protocol/behringer/X32Protocol.h b/src/protocol/behringer/X32Protocol.h index 38780a5..021c330 100644 --- a/src/protocol/behringer/X32Protocol.h +++ b/src/protocol/behringer/X32Protocol.h @@ -99,6 +99,7 @@ class X32Protocol : public MixerProtocol { void startReconnection(); void updateLatency(qint64 roundTripMs); void processResponse(const QString& path, const QVariant& value); + void parseMeters(const QByteArray& blob); MixerCapabilities m_capabilities; OscTransport m_transport; diff --git a/tests/test_cuezero.cpp b/tests/test_cuezero.cpp index 85af8d5..ae14eee 100644 --- a/tests/test_cuezero.cpp +++ b/tests/test_cuezero.cpp @@ -12,7 +12,7 @@ class TestCueZero : public QObject { private slots: void apply_pushesSceneLevelsAndLabels() { LoopbackProtocol mixer(MixerCapabilities::forConsole(ConsoleType::Loopback)); - mixer.connect("", 0); // loopback connects synchronously + QVERIFY(mixer.connect("", 0)); // loopback connects synchronously QVERIFY(mixer.isConnected()); CueZero cz; @@ -36,7 +36,7 @@ class TestCueZero : public QObject { void apply_noSceneWhenBaseSceneUnset() { LoopbackProtocol mixer(MixerCapabilities::forConsole(ConsoleType::Loopback)); - mixer.connect("", 0); + QVERIFY(mixer.connect("", 0)); CueZero cz; cz.setLevel("/ch/03/mix/fader", 0.8); From 4d08ed87b92576ac8a7582bf15485ff7a43017e1 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Tue, 30 Jun 2026 19:43:16 -0400 Subject: [PATCH 06/92] fix: verify Allen & Heath SQ NRPN encoding against official spec --- .../allenheath/AllenHeathMidiProtocol.cpp | 33 +++++------ .../allenheath/AllenHeathMidiProtocol.h | 18 +++--- tests/CMakeLists.txt | 14 +++++ tests/test_allenheath_midi.cpp | 56 +++++++++++++++++++ 4 files changed, 98 insertions(+), 23 deletions(-) create mode 100644 tests/test_allenheath_midi.cpp diff --git a/src/protocol/allenheath/AllenHeathMidiProtocol.cpp b/src/protocol/allenheath/AllenHeathMidiProtocol.cpp index e8a454f..6e18168 100644 --- a/src/protocol/allenheath/AllenHeathMidiProtocol.cpp +++ b/src/protocol/allenheath/AllenHeathMidiProtocol.cpp @@ -66,20 +66,15 @@ void AllenHeathMidiProtocol::sendParameter(const QString& path, const QVariant& QString param = parts[3]; if (param == "fader") { - // DCA fader: NRPN message - // value is 0.0-1.0, convert to 14-bit MIDI value (0-16383) + // DCA level: 14-bit NRPN (SQ Issue 5, Master Sends/Control p24) int midiValue = qBound(0, static_cast(value.toFloat() * 16383.0f), 16383); - int msb = (midiValue >> 7) & 0x7F; - int lsb = midiValue & 0x7F; - - // Allen & Heath NRPN for DCA fader - // NRPN MSB = 0x63, NRPN LSB varies by DCA - QByteArray msg = buildNRPNMessage(0, 0x63, dca - 1, msb, lsb); + QByteArray msg = buildNRPNMessage(0, DCA_LEVEL_MSB, DCA_LEVEL_LSB_BASE + dca - 1, + (midiValue >> 7) & 0x7F, midiValue & 0x7F); m_transport.send(msg); } else if (param == "mute") { - // DCA mute: Control Change - int muteValue = value.toBool() ? 127 : 0; - QByteArray msg = buildControlChange(0, 0x50 + dca - 1, muteValue); + // SQ mute is an NRPN; value-fine 1 = muted, 0 = unmuted (p11/p21) + QByteArray msg = + buildNRPNMessage(0, DCA_MUTE_MSB, dca - 1, 0x00, value.toBool() ? 0x01 : 0x00); m_transport.send(msg); } } @@ -90,12 +85,14 @@ void AllenHeathMidiProtocol::sendParameter(const QString& path, const QVariant& QString param = parts[3]; if (param == "fader") { - // input-channel fader: 14-bit NRPN, same value scaling as DCA. - // CH_FADER_NRPN_MSB is a placeholder — see the note in the header. + // input-channel level to LR: 14-bit NRPN (SQ Issue 5 p22) int midiValue = qBound(0, static_cast(value.toFloat() * 16383.0f), 16383); - int msb = (midiValue >> 7) & 0x7F; - int lsb = midiValue & 0x7F; - QByteArray msg = buildNRPNMessage(0, CH_FADER_NRPN_MSB, ch - 1, msb, lsb); + QByteArray msg = buildNRPNMessage(0, CH_LEVEL_TO_LR_MSB, ch - 1, + (midiValue >> 7) & 0x7F, midiValue & 0x7F); + m_transport.send(msg); + } else if (param == "mute") { + QByteArray msg = + buildNRPNMessage(0, CH_MUTE_MSB, ch - 1, 0x00, value.toBool() ? 0x01 : 0x00); m_transport.send(msg); } } @@ -109,6 +106,10 @@ void AllenHeathMidiProtocol::setChannelFader(int channel, double level) { sendParameter(QString("/ch/%1/fader").arg(channel), static_cast(level)); } +void AllenHeathMidiProtocol::setChannelMute(int channel, bool muted) { + sendParameter(QString("/ch/%1/mute").arg(channel), muted); +} + QVariant AllenHeathMidiProtocol::getParameter(const QString& path) { return m_parameterCache.value(path); } diff --git a/src/protocol/allenheath/AllenHeathMidiProtocol.h b/src/protocol/allenheath/AllenHeathMidiProtocol.h index 0eeeb69..0377d0e 100644 --- a/src/protocol/allenheath/AllenHeathMidiProtocol.h +++ b/src/protocol/allenheath/AllenHeathMidiProtocol.h @@ -69,16 +69,20 @@ class AllenHeathMidiProtocol : public MixerProtocol { // capabilities [[nodiscard]] const MixerCapabilities& capabilities() const override { return m_capabilities; } - // Input-channel fader level via NRPN (used by actor-voice levels + fades). - // NOTE: CH_FADER_NRPN_MSB is a placeholder for the input-channel -> LR level - // parameter MSB; set it from the A&H SQ MIDI Protocol Issue 5 reference tables - // before driving real hardware: - // https://www.allen-heath.com/content/uploads/2023/11/SQ-MIDI-Protocol-Issue5.pdf - // Channel mute over A&H MIDI uses MIDI notes and is left unimplemented here. + // Input-channel fader level + mute via NRPN. Parameter numbers verified + // against the A&H SQ MIDI Protocol Issue 5 reference tables (Inputs-to-LR + // level p22, mutes p21, DCA level/mute p24/p21). These are the SQ numbers; + // GLD uses a different map (documented limitation of the shared base). void setChannelFader(int channel, double level) override; + void setChannelMute(int channel, bool muted) override; protected: - static constexpr int CH_FADER_NRPN_MSB = 0x40; // placeholder — verify vs SQ MIDI tables + // 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 = 0x20 + N-1) + static constexpr int DCA_LEVEL_LSB_BASE = 0x20; // + 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); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f045520..2a57e9f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -184,6 +184,20 @@ if(WIN32) endif() add_test(NAME AllenHeathParsingTest COMMAND test_allenheath_parsing) +add_executable(test_allenheath_midi + test_allenheath_midi.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/allenheath/AllenHeathMidiProtocol.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_midi PRIVATE Qt6::Core Qt6::Network Qt6::Test) +target_include_directories(test_allenheath_midi PRIVATE ${CMAKE_SOURCE_DIR}/src) +add_test(NAME AllenHeathMidiTest COMMAND test_allenheath_midi) + add_executable(test_yamaha_scp test_yamaha_scp.cpp ${CMAKE_SOURCE_DIR}/src/protocol/yamaha/YamahaProtocol.cpp diff --git a/tests/test_allenheath_midi.cpp b/tests/test_allenheath_midi.cpp new file mode 100644 index 0000000..77e2d94 --- /dev/null +++ b/tests/test_allenheath_midi.cpp @@ -0,0 +1,56 @@ +#include "protocol/MixerCapabilities.h" +#include "protocol/allenheath/AllenHeathMidiProtocol.h" +#include + +using namespace OpenMix; + +namespace { +// exposes the protected NRPN builder + satisfies the pure virtuals +class SqProbe : public AllenHeathMidiProtocol { + public: + using AllenHeathMidiProtocol::AllenHeathMidiProtocol; + using AllenHeathMidiProtocol::buildNRPNMessage; + + protected: + void initializeSnapshotParams() override {} + QString dcaFaderPath(int dca) const override { return QString("/dca/%1/fader").arg(dca); } + QString dcaMutePath(int dca) const override { return QString("/dca/%1/mute").arg(dca); } +}; +} // 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). +class TestAllenHeathMidi : public QObject { + Q_OBJECT + + SqProbe* p = nullptr; + + private slots: + void init() { p = new SqProbe(MixerCapabilities::forConsole(ConsoleType::Loopback)); } + void cleanup() { + delete p; + p = nullptr; + } + + void nrpn_matchesOfficialMuteExamples() { + // p11: "Ip1, Mute On, Ch1" = B0 63 00 B0 62 00 B0 06 00 B0 26 01 + QCOMPARE(p->buildNRPNMessage(0, 0x00, 0x00, 0x00, 0x01), + QByteArray::fromHex("B06300B06200B00600B02601")); + // p11: "LR mix, Mute Off, Ch1" = B0 63 00 B0 62 44 B0 06 00 B0 26 00 + QCOMPARE(p->buildNRPNMessage(0, 0x00, 0x44, 0x00, 0x00), + QByteArray::fromHex("B06300B06244B00600B02600")); + } + + void channelFader_usesVerifiedLrLevelParam() { + // input ch1 -> LR level at unity: MSB 0x40, LSB 0x00, 14-bit 16383 (VC/VF 7F/7F) + QCOMPARE(p->buildNRPNMessage(0, 0x40, 0x00, 0x7F, 0x7F), + QByteArray::fromHex("B06340B06200B0067FB0267F")); + // ch5 -> LR uses LSB = 4 (N-1) + QCOMPARE(p->buildNRPNMessage(0, 0x40, 0x04, 0x40, 0x00).left(6), + QByteArray::fromHex("B06340B06204")); + } +}; + +QTEST_MAIN(TestAllenHeathMidi) +#include "test_allenheath_midi.moc" From dafed0b4b2961c3cdb27fd3c7b1931c466a11513 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Tue, 30 Jun 2026 19:50:34 -0400 Subject: [PATCH 07/92] chore: terse comments, drop external references --- src/app/Application.cpp | 2 +- src/app/Application.h | 4 ++-- src/app/QLabClient.h | 3 +-- src/core/ActorProfile.h | 2 +- src/core/ChannelMonitor.h | 2 +- src/core/CueZero.h | 2 +- src/core/Position.h | 2 +- src/core/TimecodeTrigger.h | 4 ++-- src/protocol/yamaha/YamahaProtocol.h | 2 +- src/ui/ActorGroupIo.h | 4 ++-- src/ui/ActorSetupPanel.cpp | 4 ++-- src/ui/ActorSetupPanel.h | 2 +- tests/CMakeLists.txt | 2 +- 13 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/app/Application.cpp b/src/app/Application.cpp index 7b758f4..76967c2 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -70,7 +70,7 @@ Application::Application(QObject* parent) : QObject(parent) { // outbound QLab / DAW remote m_qLabClient = new QLabClient(this); - // Phase 5 services + // timecode triggers + channel monitor m_timecodeTriggers = new TimecodeTriggerList(this); m_channelMonitor = new ChannelMonitor(this); diff --git a/src/app/Application.h b/src/app/Application.h index b52a391..861c2ae 100644 --- a/src/app/Application.h +++ b/src/app/Application.h @@ -73,7 +73,7 @@ class Application : public QObject { // outbound QLab / DAW remote [[nodiscard]] QLabClient* qLabClient() { return m_qLabClient; } - // Phase 5 services + // timecode triggers + channel monitor [[nodiscard]] TimecodeTriggerList* timecodeTriggers() { return m_timecodeTriggers; } [[nodiscard]] ChannelMonitor* channelMonitor() { return m_channelMonitor; } @@ -139,7 +139,7 @@ class Application : public QObject { // outbound QLab / DAW remote QLabClient* m_qLabClient; - // Phase 5: timecode-triggered cues + channel silence/clip monitoring + // timecode-triggered cues + channel silence/clip monitoring TimecodeTriggerList* m_timecodeTriggers; ChannelMonitor* m_channelMonitor; }; diff --git a/src/app/QLabClient.h b/src/app/QLabClient.h index 0b8e966..011db73 100644 --- a/src/app/QLabClient.h +++ b/src/app/QLabClient.h @@ -7,8 +7,7 @@ namespace OpenMix { // Outbound DAW remote: fires cues in QLab (or any OSC-controllable playback app) -// when an OpenMix cue carrying a linked QLab cue id is executed. Mirrors -// TheatreMix's dawRemote / qLabCue feature, including an optional pre-roll delay. +// when a cue carrying a linked QLab cue id is executed, with an optional pre-roll. class QLabClient : public QObject { Q_OBJECT diff --git a/src/core/ActorProfile.h b/src/core/ActorProfile.h index 2e3e469..c526d8a 100644 --- a/src/core/ActorProfile.h +++ b/src/core/ActorProfile.h @@ -44,7 +44,7 @@ struct VoiceData { }; // An actor's stored voice for one profile slot: the main voice plus a backup copy -// (spare-mic safe set, mirroring TheatreMix's /backup/* data). +// (spare-mic safe set). class ActorProfile { public: [[nodiscard]] const VoiceData& main() const { return m_main; } diff --git a/src/core/ChannelMonitor.h b/src/core/ChannelMonitor.h index 716cdc7..76b8283 100644 --- a/src/core/ChannelMonitor.h +++ b/src/core/ChannelMonitor.h @@ -7,7 +7,7 @@ class QTimer; namespace OpenMix { -// Per-channel scribble-strip health, à la TheatreMix's silence/clip colouring. +// Per-channel silence/clip state for scribble-strip colouring. enum class ChannelState { Normal = 0, // audio present in band Silent = 1, // below the silence floor for longer than the silence timeout diff --git a/src/core/CueZero.h b/src/core/CueZero.h index 0b6b585..28805e5 100644 --- a/src/core/CueZero.h +++ b/src/core/CueZero.h @@ -9,7 +9,7 @@ namespace OpenMix { class MixerProtocol; -// Cue Zero (TheatreMix): the show's base / reset state. Recalling it returns the +// Cue Zero: the show's base / reset state. Recalling it returns the // console to a known starting point before the first real cue -- an optional // base scene plus a set of "level" parameters and "label" (name) parameters. // Its parameters double as PlaybackGuard safe-values (what PANIC restores to). diff --git a/src/core/Position.h b/src/core/Position.h index a1929bd..cc2319a 100644 --- a/src/core/Position.h +++ b/src/core/Position.h @@ -10,7 +10,7 @@ namespace OpenMix { -// A named spatial position (TheatreMix "positions"): a reusable pan/delay +// A named spatial position: a reusable pan/delay // preset that a cue can assign to one or more input channels so an actor's // voice images to a consistent place in the room. class Position { diff --git a/src/core/TimecodeTrigger.h b/src/core/TimecodeTrigger.h index 28c1551..1b5c518 100644 --- a/src/core/TimecodeTrigger.h +++ b/src/core/TimecodeTrigger.h @@ -25,8 +25,8 @@ struct TimecodeTrigger { // Registry of timecode triggers. Fed incoming timecode via onTimecode() (wire it // to MidiInputManager::timecodeChanged); emits triggerFired() with the target cue -// number as timecode crosses each trigger's point, so the orchestrator can route -// it to PlaybackEngine (goToNumber + go). TheatreMix's "timecode" cue list. +// number as timecode crosses each trigger's point, so the caller can route it to +// PlaybackEngine (goToNumber + go). class TimecodeTriggerList : public QObject { Q_OBJECT diff --git a/src/protocol/yamaha/YamahaProtocol.h b/src/protocol/yamaha/YamahaProtocol.h index 2beef9c..7b49e47 100644 --- a/src/protocol/yamaha/YamahaProtocol.h +++ b/src/protocol/yamaha/YamahaProtocol.h @@ -13,7 +13,7 @@ namespace OpenMix { // Yamaha SCP (Remote Control Protocol) driver. // // ASCII line protocol over TCP port 49280, used by CL / QL / Rivage / DM7 -// consoles (the same transport TheatreMix drives). Commands are LF-terminated +// consoles. Commands are LF-terminated // text lines: // // set
\n diff --git a/src/ui/ActorGroupIo.h b/src/ui/ActorGroupIo.h index b16ffbc..e1f136f 100644 --- a/src/ui/ActorGroupIo.h +++ b/src/ui/ActorGroupIo.h @@ -9,8 +9,8 @@ #include // Serialization helpers for exchanging actors between shows: "actor values" -// (the whole cast) and "actor groups" (a named subset), mirroring TheatreMix's -// import/export and load/save-group actions in its Actor Setup dialog. +// (the whole cast) and "actor groups" (a named subset), for import/export and +// load/save-group actions. // // Header-only and free of any widget/Qt-GUI dependency so it links into headless // unit tests. The on-disk format wraps the same per-actor JSON the core model diff --git a/src/ui/ActorSetupPanel.cpp b/src/ui/ActorSetupPanel.cpp index 3126659..6c8467d 100644 --- a/src/ui/ActorSetupPanel.cpp +++ b/src/ui/ActorSetupPanel.cpp @@ -43,8 +43,8 @@ namespace OpenMix { // --------------------------------------------------------------------------- // VoiceEditorWidget: edits one VoiceData (a channel "voice"). Every parameter // group is gated by a "Set ..." checkbox so the resulting VoiceData only carries -// the fields the operator actually opted into (matching the optional<> model and -// TheatreMix's partial-voice semantics). Plain QWidget (no signals) — change +// the fields the operator actually opted into (matching the optional<> model). +// Plain QWidget (no signals) — change // notification is delivered through the onChanged callback so no moc is needed. // --------------------------------------------------------------------------- class VoiceEditorWidget : public QWidget { diff --git a/src/ui/ActorSetupPanel.h b/src/ui/ActorSetupPanel.h index 891227c..e8345d6 100644 --- a/src/ui/ActorSetupPanel.h +++ b/src/ui/ActorSetupPanel.h @@ -19,7 +19,7 @@ class Actor; class ActorProfileLibrary; class VoiceEditorWidget; -// Cast-management panel mirroring TheatreMix's Actor Setup dialog: an actor tree +// Cast-management panel: an actor tree // with add/remove/move controls, a per-actor voice editor (gain/HPF/EQ/dynamics) // for each profile slot plus the spare-mic backup voice, profile-slot management, // a backup-channel toggle, copy/paste/import/export of actor values, and diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2a57e9f..10df259 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -226,7 +226,7 @@ if(WIN32) endif() add_test(NAME YamahaScpTest COMMAND test_yamaha_scp) -# --- Phase 5 backend: positions, cue zero, timecode, channel monitoring --- +# --- positions, cue zero, timecode, channel monitoring --- add_executable(test_position test_position.cpp From d7f98fcfc43a491704b2314d24ad25547859d142 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Tue, 30 Jun 2026 23:46:37 -0400 Subject: [PATCH 08/92] feat: console scribble strips and ensembles --- CMakeLists.txt | 6 + src/app/Application.cpp | 18 ++ src/app/Application.h | 7 + src/core/Ensemble.cpp | 147 +++++++++++++ src/core/Ensemble.h | 84 ++++++++ src/core/ScribbleController.cpp | 109 ++++++++++ src/core/ScribbleController.h | 61 ++++++ src/core/Show.cpp | 18 +- src/core/Show.h | 6 + src/protocol/MixerProtocol.cpp | 2 + src/protocol/MixerProtocol.h | 5 + src/protocol/behringer/WingProtocol.cpp | 10 + src/protocol/behringer/WingProtocol.h | 4 + src/protocol/behringer/X32Protocol.cpp | 10 + src/protocol/behringer/X32Protocol.h | 4 + src/protocol/yamaha/YamahaProtocol.cpp | 16 ++ src/protocol/yamaha/YamahaProtocol.h | 8 + src/ui/EnsemblePanel.cpp | 272 ++++++++++++++++++++++++ src/ui/EnsemblePanel.h | 58 +++++ src/ui/MainWindow.cpp | 32 +++ src/ui/MainWindow.h | 5 + tests/CMakeLists.txt | 37 ++++ tests/test_actor_profiles.cpp | 2 +- tests/test_ensembles.cpp | 140 ++++++++++++ tests/test_scribble.cpp | 180 ++++++++++++++++ tests/test_yamaha_scp.cpp | 8 + 26 files changed, 1246 insertions(+), 3 deletions(-) create mode 100644 src/core/Ensemble.cpp create mode 100644 src/core/Ensemble.h create mode 100644 src/core/ScribbleController.cpp create mode 100644 src/core/ScribbleController.h create mode 100644 src/ui/EnsemblePanel.cpp create mode 100644 src/ui/EnsemblePanel.h create mode 100644 tests/test_ensembles.cpp create mode 100644 tests/test_scribble.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 15d5809..1adb4d0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -95,11 +95,13 @@ set(SOURCES src/core/ActorProfile.cpp src/core/Actor.cpp src/core/ActorProfileLibrary.cpp + src/core/Ensemble.cpp src/core/FadeEngine.cpp src/core/Position.cpp src/core/CueZero.cpp src/core/TimecodeTrigger.cpp src/core/ChannelMonitor.cpp + src/core/ScribbleController.cpp src/core/AppLogger.cpp src/core/ConnectionLogBridge.cpp src/ui/MainWindow.cpp @@ -117,6 +119,7 @@ set(SOURCES src/ui/MacroPreviewWidget.cpp src/ui/DCAMappingPanel.cpp src/ui/ActorSetupPanel.cpp + src/ui/EnsemblePanel.cpp src/ui/RemoteControlDialog.cpp src/ui/PopOutWindow.cpp src/ui/BubbleButton.cpp @@ -187,12 +190,14 @@ set(HEADERS src/core/ActorProfile.h src/core/Actor.h src/core/ActorProfileLibrary.h + src/core/Ensemble.h src/core/FadeCurve.h src/core/FadeEngine.h src/core/Position.h src/core/CueZero.h src/core/TimecodeTrigger.h src/core/ChannelMonitor.h + src/core/ScribbleController.h src/core/AppLogger.h src/core/ConnectionLogBridge.h src/ui/MainWindow.h @@ -211,6 +216,7 @@ set(HEADERS src/ui/DCAMappingPanel.h src/ui/ActorSetupPanel.h src/ui/ActorGroupIo.h + src/ui/EnsemblePanel.h src/ui/RemoteControlDialog.h src/ui/PopOutWindow.h src/ui/BubbleButton.h diff --git a/src/app/Application.cpp b/src/app/Application.cpp index 76967c2..6d5e05b 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -17,6 +17,7 @@ #include "core/PlaybackEngine.h" #include "core/PlaybackGuard.h" #include "core/PlaybackLogger.h" +#include "core/ScribbleController.h" #include "core/ShortcutManager.h" #include "core/Show.h" #include "io/AutosaveManager.h" @@ -74,6 +75,9 @@ Application::Application(QObject* parent) : QObject(parent) { m_timecodeTriggers = new TimecodeTriggerList(this); m_channelMonitor = new ChannelMonitor(this); + // scribble-strip driver + m_scribbleController = new ScribbleController(this); + // application logging m_appLogger = new AppLogger(this); m_connectionLogBridge = new ConnectionLogBridge(m_appLogger, this); @@ -201,6 +205,16 @@ void Application::initialize() { m_playbackEngine->goToNumber(cueNumber); m_playbackEngine->go(); }); + + // scribble strips: actor names + cue number + silence/clip colouring + m_scribbleController->setActorLibrary(m_show->actorProfileLibrary()); + m_scribbleController->setCueList(m_show->cueList()); + connect(m_show->actorProfileLibrary(), &ActorProfileLibrary::changed, m_scribbleController, + &ScribbleController::onActorLibraryChanged); + connect(m_channelMonitor, &ChannelMonitor::channelStateChanged, m_scribbleController, + &ScribbleController::onChannelStateChanged); + connect(m_playbackEngine, &PlaybackEngine::currentCueChanged, m_scribbleController, + &ScribbleController::onCurrentCueChanged); } void Application::setupMixerConnection(const QString& type, const QString& host, int port) { @@ -217,6 +231,9 @@ void Application::setupMixerConnection(const QString& type, const QString& host, connect(m_mixer, &MixerProtocol::channelMeter, m_channelMonitor, [this](int channel, float level) { m_channelMonitor->onLevel(channel, level); }); + // scribble strips push actor names/colours to this console + m_scribbleController->setMixer(m_mixer); + m_mixer->connect(host, port); QSettings settings; @@ -255,6 +272,7 @@ void Application::connectToDiscoveredConsole(const DiscoveredConsole& console) { void Application::disconnectFromMixer() { if (m_mixer) { m_connectionLogBridge->detachFromMixer(); + m_scribbleController->setMixer(nullptr); m_mixer->disconnect(); m_playbackEngine->setMixer(nullptr); delete m_mixer; diff --git a/src/app/Application.h b/src/app/Application.h index 861c2ae..94717d6 100644 --- a/src/app/Application.h +++ b/src/app/Application.h @@ -26,6 +26,7 @@ class OscRemoteServer; class QLabClient; class TimecodeTriggerList; class ChannelMonitor; +class ScribbleController; struct DiscoveredConsole; class Application : public QObject { @@ -77,6 +78,9 @@ class Application : public QObject { [[nodiscard]] TimecodeTriggerList* timecodeTriggers() { return m_timecodeTriggers; } [[nodiscard]] ChannelMonitor* channelMonitor() { return m_channelMonitor; } + // scribble-strip driver (actor names, cue number, silence/clip colours) + [[nodiscard]] ScribbleController* scribbleController() { return m_scribbleController; } + // mixer connection void connectToMixer(const QString& type, const QString& host, int port); void connectToDiscoveredConsole(const DiscoveredConsole& console); @@ -142,6 +146,9 @@ class Application : public QObject { // timecode-triggered cues + channel silence/clip monitoring TimecodeTriggerList* m_timecodeTriggers; ChannelMonitor* m_channelMonitor; + + // scribble-strip driver + ScribbleController* m_scribbleController; }; } // namespace OpenMix diff --git a/src/core/Ensemble.cpp b/src/core/Ensemble.cpp new file mode 100644 index 0000000..38517bb --- /dev/null +++ b/src/core/Ensemble.cpp @@ -0,0 +1,147 @@ +#include "Ensemble.h" + +#include +#include +#include + +namespace OpenMix { + +namespace { +// sort ascending, drop duplicates and non-positive channels +QList normalizeChannels(QList channels) { + std::sort(channels.begin(), channels.end()); + channels.erase(std::unique(channels.begin(), channels.end()), channels.end()); + channels.erase(std::remove_if(channels.begin(), channels.end(), [](int c) { return c <= 0; }), + channels.end()); + return channels; +} +} // namespace + +Ensemble::Ensemble() : m_id(QUuid::createUuid().toString(QUuid::WithoutBraces)) {} + +Ensemble::Ensemble(const QString& name) + : m_id(QUuid::createUuid().toString(QUuid::WithoutBraces)), m_name(name) {} + +void Ensemble::regenerateId() { m_id = QUuid::createUuid().toString(QUuid::WithoutBraces); } + +void Ensemble::setChannels(const QList& channels) { m_channels = normalizeChannels(channels); } + +void Ensemble::addChannel(int channel) { + if (channel <= 0 || m_channels.contains(channel)) + return; + m_channels.append(channel); + std::sort(m_channels.begin(), m_channels.end()); +} + +void Ensemble::removeChannel(int channel) { m_channels.removeAll(channel); } + +void Ensemble::setProfileSlot(const QString& slot) { + m_profileSlot = slot.isEmpty() ? QString(DEFAULT_SLOT) : slot; +} + +QJsonObject Ensemble::toJson() const { + QJsonObject json; + json["id"] = m_id; + json["name"] = m_name; + json["profileSlot"] = m_profileSlot; + + QJsonArray channelsArr; + for (int ch : m_channels) + channelsArr.append(ch); + json["channels"] = channelsArr; + + return json; +} + +Ensemble Ensemble::fromJson(const QJsonObject& json) { + Ensemble ensemble; + ensemble.m_id = json["id"].toString(); + if (ensemble.m_id.isEmpty()) + ensemble.m_id = QUuid::createUuid().toString(QUuid::WithoutBraces); + ensemble.m_name = json["name"].toString(); + ensemble.m_profileSlot = json["profileSlot"].toString(DEFAULT_SLOT); + if (ensemble.m_profileSlot.isEmpty()) + ensemble.m_profileSlot = DEFAULT_SLOT; + + QList channels; + for (const QJsonValue& val : json["channels"].toArray()) + channels.append(val.toInt()); + ensemble.m_channels = normalizeChannels(channels); + + return ensemble; +} + +EnsembleLibrary::EnsembleLibrary(QObject* parent) : QObject(parent) {} + +int EnsembleLibrary::indexOfEnsemble(const QString& id) const { + for (int i = 0; i < m_ensembles.size(); ++i) { + if (m_ensembles[i].id() == id) + return i; + } + return -1; +} + +const Ensemble* EnsembleLibrary::ensembleById(const QString& id) const { + int idx = indexOfEnsemble(id); + return idx >= 0 ? &m_ensembles[idx] : nullptr; +} + +QList EnsembleLibrary::ensemblesForChannel(int channel) const { + QList result; + for (const Ensemble& e : m_ensembles) { + if (e.hasChannel(channel)) + result.append(&e); + } + return result; +} + +void EnsembleLibrary::addEnsemble(const Ensemble& ensemble) { + m_ensembles.append(ensemble); + emit ensembleAdded(ensemble.id()); + emit changed(); +} + +void EnsembleLibrary::updateEnsemble(const QString& id, const Ensemble& ensemble) { + int idx = indexOfEnsemble(id); + if (idx < 0) + return; + m_ensembles[idx] = ensemble; + emit ensembleModified(id); + emit changed(); +} + +void EnsembleLibrary::removeEnsemble(const QString& id) { + int idx = indexOfEnsemble(id); + if (idx < 0) + return; + m_ensembles.removeAt(idx); + emit ensembleRemoved(id); + emit changed(); +} + +void EnsembleLibrary::clear() { + m_ensembles.clear(); + emit changed(); +} + +QJsonObject EnsembleLibrary::toJson() const { + QJsonObject json; + + QJsonArray ensemblesArr; + for (const Ensemble& e : m_ensembles) + ensemblesArr.append(e.toJson()); + json["ensembles"] = ensemblesArr; + + return json; +} + +void EnsembleLibrary::loadFromJson(const QJsonObject& json) { + m_ensembles.clear(); + + for (const QJsonValue& val : json["ensembles"].toArray()) + m_ensembles.append(Ensemble::fromJson(val.toObject())); + + emit changed(); +} + +} // namespace OpenMix diff --git a/src/core/Ensemble.h b/src/core/Ensemble.h new file mode 100644 index 0000000..eeeb325 --- /dev/null +++ b/src/core/Ensemble.h @@ -0,0 +1,84 @@ +#pragma once + +#include +#include +#include +#include + +namespace OpenMix { + +// A named group of input channels that share one actor-profile slot (e.g. a +// chorus of hand-mics all recalled on their "Main" voice). Channels are stored +// 1-based and kept sorted/unique. +class Ensemble { + public: + static constexpr const char* DEFAULT_SLOT = "Main"; + + Ensemble(); + explicit Ensemble(const QString& name); + + [[nodiscard]] QString id() const { return m_id; } + void setId(const QString& id) { m_id = id; } + void regenerateId(); + + [[nodiscard]] QString name() const { return m_name; } + void setName(const QString& name) { m_name = name; } + + [[nodiscard]] const QList& channels() const { return m_channels; } + void setChannels(const QList& channels); + void addChannel(int channel); + void removeChannel(int channel); + [[nodiscard]] bool hasChannel(int channel) const { return m_channels.contains(channel); } + void clearChannels() { m_channels.clear(); } + + // the profile slot each member channel resolves against, default "Main" + [[nodiscard]] QString profileSlot() const { return m_profileSlot; } + void setProfileSlot(const QString& slot); + + QJsonObject toJson() const; + [[nodiscard]] static Ensemble fromJson(const QJsonObject& json); + + private: + QString m_id; + QString m_name; + QList m_channels; + QString m_profileSlot{DEFAULT_SLOT}; +}; + +// Owns the show's ensembles. Mirrors ActorProfileLibrary: add/update/remove by +// id, JSON round-trip, and change signals so the Show can track its dirty state. +// Lives on the Show. +class EnsembleLibrary : public QObject { + Q_OBJECT + + public: + explicit EnsembleLibrary(QObject* parent = nullptr); + + [[nodiscard]] const QList& ensembles() const { return m_ensembles; } + [[nodiscard]] int count() const { return m_ensembles.size(); } + [[nodiscard]] const Ensemble* ensembleById(const QString& id) const; + + // ensembles that contain the given channel (a channel may join several) + [[nodiscard]] QList ensemblesForChannel(int channel) const; + + void addEnsemble(const Ensemble& ensemble); + void updateEnsemble(const QString& id, const Ensemble& ensemble); + void removeEnsemble(const QString& id); + void clear(); + + QJsonObject toJson() const; + void loadFromJson(const QJsonObject& json); + + signals: + void changed(); + void ensembleAdded(const QString& id); + void ensembleModified(const QString& id); + void ensembleRemoved(const QString& id); + + private: + [[nodiscard]] int indexOfEnsemble(const QString& id) const; + + QList m_ensembles; +}; + +} // namespace OpenMix diff --git a/src/core/ScribbleController.cpp b/src/core/ScribbleController.cpp new file mode 100644 index 0000000..ccf1fbf --- /dev/null +++ b/src/core/ScribbleController.cpp @@ -0,0 +1,109 @@ +#include "ScribbleController.h" + +#include "Actor.h" +#include "ActorProfileLibrary.h" +#include "Cue.h" +#include "CueList.h" +#include "protocol/MixerProtocol.h" + +#include + +namespace OpenMix { + +namespace { +// short strip label for a cue number: "Q1", "Q2.5" (no trailing .0) +QString cueLabel(double number) { + const bool integral = number == static_cast(static_cast(number)); + return QStringLiteral("Q") + QString::number(number, 'f', integral ? 0 : 1); +} +} // namespace + +ScribbleController::ScribbleController(QObject* parent) : QObject(parent) {} + +void ScribbleController::setMixer(MixerProtocol* mixer) { + if (m_mixer == mixer) + return; + if (m_mixer) + disconnect(m_mixer, nullptr, this, nullptr); + + m_mixer = mixer; + + if (m_mixer) { + // push names once the console finishes connecting + connect(m_mixer, &MixerProtocol::connected, this, &ScribbleController::refreshNames); + if (m_mixer->isConnected()) + refreshNames(); + } +} + +void ScribbleController::setActorLibrary(ActorProfileLibrary* library) { m_library = library; } + +void ScribbleController::setCueList(CueList* cueList) { m_cueList = cueList; } + +void ScribbleController::setEnabled(bool enabled) { + if (m_enabled == enabled) + return; + m_enabled = enabled; + if (m_enabled) + refreshNames(); +} + +void ScribbleController::setCueNumberChannel(int channel) { + m_cueChannel = channel > 0 ? channel : 0; +} + +void ScribbleController::setStateColour(ChannelState state, int colour) { + const int idx = static_cast(state); + if (idx >= 0 && idx < 3) + m_stateColours[idx] = colour; +} + +int ScribbleController::stateColour(ChannelState state) const { + const int idx = static_cast(state); + return (idx >= 0 && idx < 3) ? m_stateColours[idx] : m_stateColours[0]; +} + +void ScribbleController::refreshNames() { + if (!m_enabled || !m_mixer || !m_library) + return; + + // resolve one winning actor per channel (lead over understudy) so a channel + // shared by several cast members shows a single, stable name. + QSet channels; + for (const Actor& a : m_library->actors()) { + if (a.channel() > 0) + channels.insert(a.channel()); + } + + for (int ch : channels) { + if (ch == m_cueChannel) + continue; // reserved for the cue number + if (const Actor* actor = m_library->actorForChannel(ch)) + m_mixer->setChannelName(ch, actor->name()); + } +} + +void ScribbleController::onChannelStateChanged(int channel, int state) { + if (!m_enabled || !m_mixer || channel <= 0) + return; + + int idx = state; + if (idx < 0 || idx >= 3) + idx = static_cast(ChannelState::Normal); + m_mixer->setChannelColour(channel, m_stateColours[idx]); +} + +void ScribbleController::onCurrentCueChanged(int cueIndex) { + if (!m_enabled || !m_mixer || m_cueChannel <= 0) + return; + + QString label = QStringLiteral("--"); + if (m_cueList && cueIndex >= 0 && cueIndex < m_cueList->count()) + label = cueLabel(m_cueList->at(cueIndex).number()); + + m_mixer->setChannelName(m_cueChannel, label); +} + +void ScribbleController::onActorLibraryChanged() { refreshNames(); } + +} // namespace OpenMix diff --git a/src/core/ScribbleController.h b/src/core/ScribbleController.h new file mode 100644 index 0000000..c7ff27a --- /dev/null +++ b/src/core/ScribbleController.h @@ -0,0 +1,61 @@ +#pragma once + +#include "ChannelMonitor.h" // ChannelState +#include +#include + +namespace OpenMix { + +class MixerProtocol; +class ActorProfileLibrary; +class CueList; + +// Drives console scribble strips from show data. It writes each channel's +// assigned actor name to that channel's strip, writes the current cue number to +// a configurable strip, and colours strips from ChannelMonitor silence/clip +// state. All pushes go through MixerProtocol::setChannelName / setChannelColour, +// which are no-ops on drivers that cannot address scribble strips. +class ScribbleController : public QObject { + Q_OBJECT + + public: + explicit ScribbleController(QObject* parent = nullptr); + + void setMixer(MixerProtocol* mixer); + void setActorLibrary(ActorProfileLibrary* library); + void setCueList(CueList* cueList); + + void setEnabled(bool enabled); + [[nodiscard]] bool isEnabled() const noexcept { return m_enabled; } + + // strip that shows the current cue number; 0 disables (no strip is used and + // actor names are never suppressed). 1-based. + void setCueNumberChannel(int channel); + [[nodiscard]] int cueNumberChannel() const noexcept { return m_cueChannel; } + + // driver-mapped colour index shown for each monitor state. Defaults suit the + // X32 palette (white / blue / red); configurable so other consoles fit. + void setStateColour(ChannelState state, int colour); + [[nodiscard]] int stateColour(ChannelState state) const; + + // push every assigned actor name to its channel now + void refreshNames(); + + public slots: + void onChannelStateChanged(int channel, int state); + void onCurrentCueChanged(int cueIndex); + void onActorLibraryChanged(); + + private: + MixerProtocol* m_mixer = nullptr; + ActorProfileLibrary* m_library = nullptr; + CueList* m_cueList = nullptr; + + bool m_enabled = true; + int m_cueChannel = 0; + + // indexed by int(ChannelState): Normal, Silent, Clipping + int m_stateColours[3] = {7, 4, 1}; +}; + +} // namespace OpenMix diff --git a/src/core/Show.cpp b/src/core/Show.cpp index 72c58ef..7da0f32 100644 --- a/src/core/Show.cpp +++ b/src/core/Show.cpp @@ -22,11 +22,12 @@ MixerConfig MixerConfig::fromJson(const QJsonObject& json) { Show::Show(QObject* parent) : QObject(parent), m_cueList(this), m_dcaMapping(this), m_actorProfileLibrary(this), - m_positionLibrary(this), m_cueZero(this) { + m_positionLibrary(this), m_ensembleLibrary(this), m_cueZero(this) { connectCueListSignals(); connectDcaMappingSignals(); connectActorLibrarySignals(); connectPositionLibrarySignals(); + connectEnsembleLibrarySignals(); connectCueZeroSignals(); newShow(); } @@ -76,6 +77,10 @@ void Show::connectPositionLibrarySignals() { connect(&m_positionLibrary, &PositionLibrary::changed, this, &Show::checkModifiedState); } +void Show::connectEnsembleLibrarySignals() { + connect(&m_ensembleLibrary, &EnsembleLibrary::changed, this, &Show::checkModifiedState); +} + void Show::connectCueZeroSignals() { connect(&m_cueZero, &CueZero::changed, this, &Show::checkModifiedState); } @@ -92,13 +97,14 @@ void Show::newShow() { m_dcaMapping.clear(); m_actorProfileLibrary.clear(); m_positionLibrary.clear(); + m_ensembleLibrary.clear(); m_cueZero.clear(); m_isDirty = false; } QJsonObject Show::toJson() const { QJsonObject json; - json["version"] = "1.2"; + json["version"] = "1.3"; json["name"] = m_name; json["author"] = m_author; json["notes"] = m_notes; @@ -107,6 +113,7 @@ QJsonObject Show::toJson() const { json["dcaMapping"] = m_dcaMapping.toJson(); json["actors"] = m_actorProfileLibrary.toJson(); json["positions"] = m_positionLibrary.toJson(); + json["ensembles"] = m_ensembleLibrary.toJson(); json["cueZero"] = m_cueZero.toJson(); return json; } @@ -143,6 +150,13 @@ void Show::fromJson(const QJsonObject& json) { m_positionLibrary.clear(); } + // ensemble library (added in show version 1.3) + if (json.contains("ensembles")) { + m_ensembleLibrary.loadFromJson(json["ensembles"].toObject()); + } else { + m_ensembleLibrary.clear(); + } + // Cue Zero base state (added in show version 1.2) if (json.contains("cueZero")) { m_cueZero.loadFromJson(json["cueZero"].toObject()); diff --git a/src/core/Show.h b/src/core/Show.h index 885ec07..04e0135 100644 --- a/src/core/Show.h +++ b/src/core/Show.h @@ -4,6 +4,7 @@ #include "CueList.h" #include "CueZero.h" #include "DCAMapping.h" +#include "Ensemble.h" #include "Position.h" #include #include @@ -62,6 +63,9 @@ class Show : public QObject { [[nodiscard]] PositionLibrary* positionLibrary() { return &m_positionLibrary; } [[nodiscard]] const PositionLibrary* positionLibrary() const { return &m_positionLibrary; } + [[nodiscard]] EnsembleLibrary* ensembleLibrary() { return &m_ensembleLibrary; } + [[nodiscard]] const EnsembleLibrary* ensembleLibrary() const { return &m_ensembleLibrary; } + [[nodiscard]] CueZero* cueZero() { return &m_cueZero; } [[nodiscard]] const CueZero* cueZero() const { return &m_cueZero; } @@ -85,6 +89,7 @@ class Show : public QObject { void connectDcaMappingSignals(); void connectActorLibrarySignals(); void connectPositionLibrarySignals(); + void connectEnsembleLibrarySignals(); void connectCueZeroSignals(); QString m_name; @@ -96,6 +101,7 @@ class Show : public QObject { DCAMapping m_dcaMapping; ActorProfileLibrary m_actorProfileLibrary; PositionLibrary m_positionLibrary; + EnsembleLibrary m_ensembleLibrary; CueZero m_cueZero; bool m_isDirty = false; }; diff --git a/src/protocol/MixerProtocol.cpp b/src/protocol/MixerProtocol.cpp index be2a23c..e31001a 100644 --- a/src/protocol/MixerProtocol.cpp +++ b/src/protocol/MixerProtocol.cpp @@ -17,5 +17,7 @@ void MixerProtocol::setChannelHpf(int, bool, double) {} void MixerProtocol::setChannelEqOn(int, bool) {} void MixerProtocol::setChannelEqBand(int, int, bool, int, double, double, double) {} void MixerProtocol::setChannelDynamics(int, bool, double, double, double, double, double) {} +void MixerProtocol::setChannelName(int, const QString&) {} +void MixerProtocol::setChannelColour(int, int) {} } // namespace OpenMix diff --git a/src/protocol/MixerProtocol.h b/src/protocol/MixerProtocol.h index 8825854..e8fb42f 100644 --- a/src/protocol/MixerProtocol.h +++ b/src/protocol/MixerProtocol.h @@ -54,6 +54,11 @@ class MixerProtocol : public QObject { virtual void setChannelDynamics(int channel, bool on, double thresholdDb, double ratio, double attackMs, double releaseMs, double makeupDb); + // scribble-strip name and colour. channel is 1-based; colour is a + // driver-mapped palette index. Default no-op; OSC/SCP drivers override. + virtual void setChannelName(int channel, const QString& name); + virtual void setChannelColour(int channel, int colour); + virtual void refresh() = 0; virtual int latencyMs() const = 0; virtual const MixerCapabilities& capabilities() const; diff --git a/src/protocol/behringer/WingProtocol.cpp b/src/protocol/behringer/WingProtocol.cpp index fb68538..7fa7f3e 100644 --- a/src/protocol/behringer/WingProtocol.cpp +++ b/src/protocol/behringer/WingProtocol.cpp @@ -298,6 +298,16 @@ void WingProtocol::setChannelDynamics(int channel, bool on, double thresholdDb, sendParameter(ch + "/dyn/gain", static_cast(makeupDb)); } +void WingProtocol::setChannelName(int channel, const QString& name) { + // WING channel name node (best-effort; console truncates to display width) + sendParameter(wingChannel(channel) + "/$name", name); +} + +void WingProtocol::setChannelColour(int channel, int colour) { + // WING channel colour index (best-effort; palette differs from X32) + sendParameter(wingChannel(channel) + "/col", colour); +} + void WingProtocol::refresh() { if (m_connectionState != ConnectionState::Connected) return; diff --git a/src/protocol/behringer/WingProtocol.h b/src/protocol/behringer/WingProtocol.h index 49026c5..272a0dd 100644 --- a/src/protocol/behringer/WingProtocol.h +++ b/src/protocol/behringer/WingProtocol.h @@ -63,6 +63,10 @@ class WingProtocol : public MixerProtocol { void setChannelDynamics(int channel, bool on, double thresholdDb, double ratio, double attackMs, double releaseMs, double makeupDb) override; + // scribble strips + void setChannelName(int channel, const QString& name) override; + void setChannelColour(int channel, int colour) override; + // keep-alive void refresh() override; diff --git a/src/protocol/behringer/X32Protocol.cpp b/src/protocol/behringer/X32Protocol.cpp index 8d42217..a7f5918 100644 --- a/src/protocol/behringer/X32Protocol.cpp +++ b/src/protocol/behringer/X32Protocol.cpp @@ -309,6 +309,16 @@ void X32Protocol::setChannelDynamics(int channel, bool on, double thresholdDb, d sendParameter(ch + "/dyn/mgain", linNorm(makeupDb, 0.0, 24.0)); } +void X32Protocol::setChannelName(int channel, const QString& name) { + // scribble-strip label; the console truncates to its display width + sendParameter(x32Channel(channel) + "/config/name", name); +} + +void X32Protocol::setChannelColour(int channel, int colour) { + // /config/color is a palette index (0..15) + sendParameter(x32Channel(channel) + "/config/color", colour); +} + void X32Protocol::refresh() { if (m_connectionState != ConnectionState::Connected) return; diff --git a/src/protocol/behringer/X32Protocol.h b/src/protocol/behringer/X32Protocol.h index 021c330..21e87ca 100644 --- a/src/protocol/behringer/X32Protocol.h +++ b/src/protocol/behringer/X32Protocol.h @@ -61,6 +61,10 @@ class X32Protocol : public MixerProtocol { void setChannelDynamics(int channel, bool on, double thresholdDb, double ratio, double attackMs, double releaseMs, double makeupDb) override; + // scribble strips + void setChannelName(int channel, const QString& name) override; + void setChannelColour(int channel, int colour) override; + // keep-alive void refresh() override; diff --git a/src/protocol/yamaha/YamahaProtocol.cpp b/src/protocol/yamaha/YamahaProtocol.cpp index a83d637..e0c8808 100644 --- a/src/protocol/yamaha/YamahaProtocol.cpp +++ b/src/protocol/yamaha/YamahaProtocol.cpp @@ -151,6 +151,14 @@ QByteArray YamahaProtocol::buildChannelDynamicsThreshold(int ch, double threshol return scpSet(AddrDynaThreshold, ch, 0, std::clamp(deci, -540, 0)); } +QByteArray YamahaProtocol::buildChannelName(int ch, const QString& name) const { + return scpSet(AddrChannelName, ch, 0, name); +} + +QByteArray YamahaProtocol::buildChannelColour(int ch, int colour) const { + return scpSet(AddrChannelColor, ch, 0, colour); +} + // -------------------------------------------------------------------------- // Semantic setters (send when connected, and cache). // -------------------------------------------------------------------------- @@ -201,6 +209,14 @@ void YamahaProtocol::setChannelDynamics(int ch, bool on, double thresholdDb, dou sendCommand(buildChannelDynamicsThreshold(ch - 1, thresholdDb)); } +void YamahaProtocol::setChannelName(int ch, const QString& name) { + sendCommand(buildChannelName(ch - 1, name)); +} + +void YamahaProtocol::setChannelColour(int ch, int colour) { + sendCommand(buildChannelColour(ch - 1, colour)); +} + // -------------------------------------------------------------------------- // Connection lifecycle. // -------------------------------------------------------------------------- diff --git a/src/protocol/yamaha/YamahaProtocol.h b/src/protocol/yamaha/YamahaProtocol.h index 7b49e47..6d650e4 100644 --- a/src/protocol/yamaha/YamahaProtocol.h +++ b/src/protocol/yamaha/YamahaProtocol.h @@ -56,6 +56,8 @@ class YamahaProtocol : public MixerProtocol { static constexpr auto AddrEqBandGain = "MIXER:Current/InCh/PEQ/Band/Gain"; static constexpr auto AddrEqBandQ = "MIXER:Current/InCh/PEQ/Band/Q"; static constexpr auto AddrDynaThreshold = "MIXER:Current/InCh/Dyna2/Threshold"; + static constexpr auto AddrChannelName = "MIXER:Current/InCh/Label/Name"; + static constexpr auto AddrChannelColor = "MIXER:Current/InCh/Label/Color"; static constexpr auto SceneLib = "MIXER:Lib/Scene"; explicit YamahaProtocol(const MixerCapabilities& caps, QObject* parent = nullptr); @@ -99,6 +101,10 @@ class YamahaProtocol : public MixerProtocol { void setChannelDynamics(int ch, bool on, double thresholdDb, double ratio, double attackMs, double releaseMs, double makeupDb) override; + // scribble strips (SCP channel label name + colour index) + void setChannelName(int ch, const QString& name) override; + void setChannelColour(int ch, int colour) override; + // --- pure command builders: produce the exact SCP ASCII bytes (LF-terminated). --- [[nodiscard]] static QByteArray scpSet(const QString& address, int idx1, int idx2, int value); [[nodiscard]] static QByteArray scpSet(const QString& address, int idx1, int idx2, @@ -124,6 +130,8 @@ class YamahaProtocol : public MixerProtocol { [[nodiscard]] QByteArray buildChannelEqBandGain(int ch, int band, double gainDb) const; [[nodiscard]] QByteArray buildChannelEqBandQ(int ch, int band, double q) const; [[nodiscard]] QByteArray buildChannelDynamicsThreshold(int ch, double thresholdDb) const; + [[nodiscard]] QByteArray buildChannelName(int ch, const QString& name) const; + [[nodiscard]] QByteArray buildChannelColour(int ch, int colour) const; protected: // Head-amp (preamp) gain scaling. CL/QL/Rivage transmit centi-dB; the DM7 diff --git a/src/ui/EnsemblePanel.cpp b/src/ui/EnsemblePanel.cpp new file mode 100644 index 0000000..518859b --- /dev/null +++ b/src/ui/EnsemblePanel.cpp @@ -0,0 +1,272 @@ +#include "EnsemblePanel.h" +#include "app/Application.h" +#include "core/ActorProfileLibrary.h" +#include "core/Ensemble.h" +#include "core/Show.h" +#include "theme/Icons.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace OpenMix { + +namespace { +constexpr int EnsembleIdRole = Qt::UserRole + 1; + +// parse "1, 2, 5-8" into a sorted, unique channel list +QList parseChannels(const QString& text) { + QList channels; + const QStringList tokens = text.split(QRegularExpression("[,\\s]+"), Qt::SkipEmptyParts); + for (const QString& token : tokens) { + const int dash = token.indexOf('-'); + if (dash > 0) { + bool okA = false, okB = false; + int a = token.left(dash).toInt(&okA); + int b = token.mid(dash + 1).toInt(&okB); + if (okA && okB && a > 0 && b >= a) { + for (int c = a; c <= b; ++c) + channels.append(c); + } + } else { + bool ok = false; + int c = token.toInt(&ok); + if (ok && c > 0) + channels.append(c); + } + } + std::sort(channels.begin(), channels.end()); + channels.erase(std::unique(channels.begin(), channels.end()), channels.end()); + return channels; +} + +QString formatChannels(const QList& channels) { + QStringList parts; + for (int c : channels) + parts.append(QString::number(c)); + return parts.join(", "); +} + +QString itemText(const Ensemble& e) { + const QString name = e.name().isEmpty() ? QObject::tr("(unnamed)") : e.name(); + if (e.channels().isEmpty()) + return name; + return QString("%1 [%2]").arg(name, formatChannels(e.channels())); +} +} // namespace + +EnsemblePanel::EnsemblePanel(Application* app, QWidget* parent) : QWidget(parent), m_app(app) { + if (m_app && m_app->show()) + m_library = m_app->show()->ensembleLibrary(); + + setupUi(); + refresh(); +} + +void EnsemblePanel::setupUi() { + QVBoxLayout* mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(8, 8, 8, 8); + mainLayout->setSpacing(8); + + // toolbar + QHBoxLayout* toolbarLayout = new QHBoxLayout(); + m_addButton = new QPushButton(Icons::listAdd(), tr("Add Ensemble"), this); + m_addButton->setToolTip(tr("Create a new ensemble")); + connect(m_addButton, &QPushButton::clicked, this, &EnsemblePanel::addEnsemble); + toolbarLayout->addWidget(m_addButton); + + m_removeButton = new QPushButton(Icons::listRemove(), tr("Remove"), this); + m_removeButton->setToolTip(tr("Delete the selected ensemble")); + connect(m_removeButton, &QPushButton::clicked, this, &EnsemblePanel::removeEnsemble); + toolbarLayout->addWidget(m_removeButton); + + toolbarLayout->addStretch(); + mainLayout->addLayout(toolbarLayout); + + // ensemble list + m_list = new QListWidget(this); + connect(m_list, &QListWidget::itemSelectionChanged, this, &EnsemblePanel::onSelectionChanged); + mainLayout->addWidget(m_list, 1); + + // editor + QGroupBox* editorGroup = new QGroupBox(tr("Ensemble"), this); + QFormLayout* form = new QFormLayout(editorGroup); + + m_nameEdit = new QLineEdit(editorGroup); + m_nameEdit->setPlaceholderText(tr("Ensemble name")); + connect(m_nameEdit, &QLineEdit::editingFinished, this, &EnsemblePanel::onNameEdited); + form->addRow(tr("Name:"), m_nameEdit); + + m_channelsEdit = new QLineEdit(editorGroup); + m_channelsEdit->setPlaceholderText(tr("e.g. 1, 2, 5-8")); + m_channelsEdit->setToolTip(tr("Comma-separated channels and ranges")); + connect(m_channelsEdit, &QLineEdit::editingFinished, this, &EnsemblePanel::onChannelsEdited); + form->addRow(tr("Channels:"), m_channelsEdit); + + m_slotCombo = new QComboBox(editorGroup); + m_slotCombo->setToolTip(tr("Actor profile slot each member channel recalls")); + connect(m_slotCombo, &QComboBox::currentTextChanged, this, + &EnsemblePanel::onProfileSlotChanged); + form->addRow(tr("Profile slot:"), m_slotCombo); + + m_summaryLabel = new QLabel(editorGroup); + m_summaryLabel->setWordWrap(true); + form->addRow(tr("Members:"), m_summaryLabel); + + mainLayout->addWidget(editorGroup); +} + +void EnsemblePanel::refresh() { + if (m_app && m_app->show()) + m_library = m_app->show()->ensembleLibrary(); + + // repopulate the profile-slot options from the actor library + m_updatingUi = true; + const QString keepSlot = m_slotCombo->currentText(); + m_slotCombo->clear(); + if (m_app && m_app->show() && m_app->show()->actorProfileLibrary()) + m_slotCombo->addItems(m_app->show()->actorProfileLibrary()->profileSlots()); + if (!keepSlot.isEmpty() && m_slotCombo->findText(keepSlot) < 0) + m_slotCombo->addItem(keepSlot); + m_updatingUi = false; + + populateList(); + updateEditor(); +} + +void EnsemblePanel::populateList() { + const QString keepId = currentEnsembleId(); + + m_updatingUi = true; + m_list->clear(); + if (m_library) { + for (const Ensemble& e : m_library->ensembles()) { + QListWidgetItem* item = new QListWidgetItem(itemText(e), m_list); + item->setData(EnsembleIdRole, e.id()); + if (e.id() == keepId) + m_list->setCurrentItem(item); + } + } + m_updatingUi = false; +} + +QString EnsemblePanel::currentEnsembleId() const { + QListWidgetItem* item = m_list->currentItem(); + return item ? item->data(EnsembleIdRole).toString() : QString(); +} + +void EnsemblePanel::setEditorEnabled(bool enabled) { + m_nameEdit->setEnabled(enabled); + m_channelsEdit->setEnabled(enabled); + m_slotCombo->setEnabled(enabled); + m_removeButton->setEnabled(enabled); +} + +void EnsemblePanel::updateEditor() { + const Ensemble* e = m_library ? m_library->ensembleById(currentEnsembleId()) : nullptr; + + m_updatingUi = true; + if (e) { + m_nameEdit->setText(e->name()); + m_channelsEdit->setText(formatChannels(e->channels())); + int idx = m_slotCombo->findText(e->profileSlot()); + if (idx < 0) { + m_slotCombo->addItem(e->profileSlot()); + idx = m_slotCombo->findText(e->profileSlot()); + } + m_slotCombo->setCurrentIndex(idx); + m_summaryLabel->setText(tr("%n channel(s)", "", e->channels().size())); + setEditorEnabled(true); + } else { + m_nameEdit->clear(); + m_channelsEdit->clear(); + m_summaryLabel->clear(); + setEditorEnabled(false); + } + m_updatingUi = false; +} + +void EnsemblePanel::addEnsemble() { + if (!m_library) + return; + Ensemble e(tr("New Ensemble")); + if (m_slotCombo->count() > 0) + e.setProfileSlot(m_slotCombo->itemText(0)); + m_library->addEnsemble(e); + + QListWidgetItem* item = new QListWidgetItem(itemText(e), m_list); + item->setData(EnsembleIdRole, e.id()); + m_list->setCurrentItem(item); + updateEditor(); + m_nameEdit->setFocus(); + m_nameEdit->selectAll(); +} + +void EnsemblePanel::removeEnsemble() { + if (!m_library) + return; + const QString id = currentEnsembleId(); + if (id.isEmpty()) + return; + m_library->removeEnsemble(id); + delete m_list->takeItem(m_list->currentRow()); + updateEditor(); +} + +void EnsemblePanel::onSelectionChanged() { + if (m_updatingUi) + return; + updateEditor(); +} + +void EnsemblePanel::onNameEdited() { + if (m_updatingUi || !m_library) + return; + const Ensemble* current = m_library->ensembleById(currentEnsembleId()); + if (!current || current->name() == m_nameEdit->text()) + return; + Ensemble e = *current; + e.setName(m_nameEdit->text()); + m_library->updateEnsemble(e.id(), e); + if (QListWidgetItem* item = m_list->currentItem()) + item->setText(itemText(e)); +} + +void EnsemblePanel::onChannelsEdited() { + if (m_updatingUi || !m_library) + return; + const Ensemble* current = m_library->ensembleById(currentEnsembleId()); + if (!current) + return; + Ensemble e = *current; + e.setChannels(parseChannels(m_channelsEdit->text())); + m_library->updateEnsemble(e.id(), e); + + m_updatingUi = true; + m_channelsEdit->setText(formatChannels(e.channels())); // reflect normalization + m_summaryLabel->setText(tr("%n channel(s)", "", e.channels().size())); + m_updatingUi = false; + if (QListWidgetItem* item = m_list->currentItem()) + item->setText(itemText(e)); +} + +void EnsemblePanel::onProfileSlotChanged() { + if (m_updatingUi || !m_library) + return; + const Ensemble* current = m_library->ensembleById(currentEnsembleId()); + if (!current || current->profileSlot() == m_slotCombo->currentText()) + return; + Ensemble e = *current; + e.setProfileSlot(m_slotCombo->currentText()); + m_library->updateEnsemble(e.id(), e); +} + +} // namespace OpenMix diff --git a/src/ui/EnsemblePanel.h b/src/ui/EnsemblePanel.h new file mode 100644 index 0000000..94a441e --- /dev/null +++ b/src/ui/EnsemblePanel.h @@ -0,0 +1,58 @@ +#pragma once + +#include + +class QComboBox; +class QLabel; +class QLineEdit; +class QListWidget; +class QPushButton; + +namespace OpenMix { + +class Application; +class EnsembleLibrary; + +// Manage the show's ensembles: named groups of channels that share one actor +// profile slot. Mirrors the pop-out panel pattern (Application-backed, refresh() +// on show). Editing writes straight back to the Show-owned EnsembleLibrary. +class EnsemblePanel : public QWidget { + Q_OBJECT + + public: + explicit EnsemblePanel(Application* app, QWidget* parent = nullptr); + + public slots: + void refresh(); + + private slots: + void addEnsemble(); + void removeEnsemble(); + void onSelectionChanged(); + void onNameEdited(); + void onChannelsEdited(); + void onProfileSlotChanged(); + + private: + void setupUi(); + void populateList(); + void updateEditor(); + void setEditorEnabled(bool enabled); + [[nodiscard]] QString currentEnsembleId() const; + + Application* m_app; + EnsembleLibrary* m_library = nullptr; + + QListWidget* m_list; + QPushButton* m_addButton; + QPushButton* m_removeButton; + + QLineEdit* m_nameEdit; + QLineEdit* m_channelsEdit; + QComboBox* m_slotCombo; + QLabel* m_summaryLabel; + + bool m_updatingUi = false; +}; + +} // namespace OpenMix diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index fb657ff..cd9b68e 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -7,6 +7,7 @@ #include "CueListView.h" #include "CueTableModel.h" #include "DCAMappingPanel.h" +#include "EnsemblePanel.h" #include "LogViewerDialog.h" #include "MixerFeedbackPanel.h" #include "PopOutWindow.h" @@ -197,6 +198,13 @@ void MainWindow::createActions() { m_showActorSetupAction->setToolTip(tr("Show/hide actor setup panel (F9)")); connect(m_showActorSetupAction, &QAction::triggered, this, &MainWindow::toggleActorSetupPanel); + m_showEnsembleAction = new QAction(Icons::actorSetup(), tr("&Ensembles"), this); + m_showEnsembleAction->setCheckable(true); + m_showEnsembleAction->setChecked(false); + m_showEnsembleAction->setShortcut(Qt::Key_F10); + m_showEnsembleAction->setToolTip(tr("Show/hide ensembles panel (F10)")); + connect(m_showEnsembleAction, &QAction::triggered, this, &MainWindow::toggleEnsemblePanel); + m_showLogViewerAction = new QAction(tr("Application &Log..."), this); m_showLogViewerAction->setShortcut(Qt::Key_F8); m_showLogViewerAction->setToolTip(tr("Show application log (F8)")); @@ -262,6 +270,7 @@ void MainWindow::registerShortcuts() { sm->registerAction("view.mixerFeedback", m_showMixerFeedbackAction, QKeySequence(Qt::Key_F6)); sm->registerAction("view.connection", m_showConnectionAction, QKeySequence(Qt::Key_F7)); sm->registerAction("view.actorSetup", m_showActorSetupAction, QKeySequence(Qt::Key_F9)); + sm->registerAction("view.ensembles", m_showEnsembleAction, QKeySequence(Qt::Key_F10)); sm->registerAction("view.logViewer", m_showLogViewerAction, QKeySequence(Qt::Key_F8)); // settings actions @@ -309,6 +318,7 @@ void MainWindow::createMenus() { m_viewMenu->addAction(m_showMixerFeedbackAction); m_viewMenu->addAction(m_showConnectionAction); m_viewMenu->addAction(m_showActorSetupAction); + m_viewMenu->addAction(m_showEnsembleAction); m_viewMenu->addSeparator(); m_viewMenu->addAction(m_showLogViewerAction); @@ -394,6 +404,16 @@ void MainWindow::createPopOutWindows() { m_showActorSetupAction->setChecked(visible); m_bubbleBar->setButtonActive("actors", visible); }); + + m_ensemblePanel = new EnsemblePanel(m_app, nullptr); + m_ensemblePopOut = new PopOutWindow("ensembles", tr("Ensembles"), this); + m_ensemblePopOut->setContentWidget(m_ensemblePanel); + m_ensemblePopOut->setMinimumContentSize(420, 480); + + connect(m_ensemblePopOut, &PopOutWindow::visibilityChanged, [this](bool visible) { + m_showEnsembleAction->setChecked(visible); + m_bubbleBar->setButtonActive("ensembles", visible); + }); } void MainWindow::createBubbleBar() { @@ -403,6 +423,7 @@ void MainWindow::createBubbleBar() { m_bubbleBar->addButton("mixer", Icons::audioVolume(), tr("Mixer Feedback (F6)")); m_bubbleBar->addButton("connection", Icons::network(), tr("Connection (F7)")); m_bubbleBar->addButton("actors", Icons::actorSetup(), tr("Actor Setup (F9)")); + m_bubbleBar->addButton("ensembles", Icons::actor(), tr("Ensembles (F10)")); connect(m_bubbleBar, &BubbleBar::buttonClicked, this, &MainWindow::onBubbleButtonClicked); @@ -419,6 +440,8 @@ void MainWindow::onBubbleButtonClicked(const QString& id, [[maybe_unused]] bool toggleConnectionPanel(); } else if (id == "actors") { toggleActorSetupPanel(); + } else if (id == "ensembles") { + toggleEnsemblePanel(); } } @@ -699,6 +722,15 @@ void MainWindow::toggleActorSetupPanel() { } } +void MainWindow::toggleEnsemblePanel() { + if (m_ensemblePopOut->isVisible()) { + m_ensemblePopOut->hide(); + } else { + m_ensemblePopOut->showAndRestore(); + m_ensemblePanel->refresh(); + } +} + void MainWindow::updateTitle() { QString title = m_app->show()->name(); if (m_app->show()->isModified()) { diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 519eab7..e5e175d 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -18,6 +18,7 @@ class ConnectionPanel; class MixerFeedbackPanel; class DCAMappingPanel; class ActorSetupPanel; +class EnsemblePanel; class PopOutWindow; class BubbleBar; class PlaybackGuard; @@ -58,6 +59,7 @@ class MainWindow : public QMainWindow { void toggleMixerFeedbackPanel(); void toggleDCAMappingPanel(); void toggleActorSetupPanel(); + void toggleEnsemblePanel(); // update UI state void updateTitle(); @@ -111,12 +113,14 @@ class MainWindow : public QMainWindow { MixerFeedbackPanel* m_mixerFeedbackPanel; DCAMappingPanel* m_dcaMappingPanel; ActorSetupPanel* m_actorSetupPanel; + EnsemblePanel* m_ensemblePanel; // pop-out windows PopOutWindow* m_connectionPopOut; PopOutWindow* m_mixerFeedbackPopOut; PopOutWindow* m_dcaMappingPopOut; PopOutWindow* m_actorSetupPopOut; + PopOutWindow* m_ensemblePopOut; // bubble bar BubbleBar* m_bubbleBar; @@ -163,6 +167,7 @@ class MainWindow : public QMainWindow { QAction* m_showMixerFeedbackAction; QAction* m_showDCAMappingAction; QAction* m_showActorSetupAction; + QAction* m_showEnsembleAction; QAction* m_showLogViewerAction; // settings actions diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 10df259..6e390ca 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -36,6 +36,7 @@ add_executable(test_show ${CMAKE_SOURCE_DIR}/src/core/ActorProfile.cpp ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp ${CMAKE_SOURCE_DIR}/src/core/ActorProfileLibrary.cpp + ${CMAKE_SOURCE_DIR}/src/core/Ensemble.cpp ${CMAKE_SOURCE_DIR}/src/core/Position.cpp ${CMAKE_SOURCE_DIR}/src/core/CueZero.cpp ) @@ -57,6 +58,7 @@ add_executable(test_actor_profiles ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp ${CMAKE_SOURCE_DIR}/src/core/Show.cpp + ${CMAKE_SOURCE_DIR}/src/core/Ensemble.cpp ${CMAKE_SOURCE_DIR}/src/core/Position.cpp ${CMAKE_SOURCE_DIR}/src/core/CueZero.cpp ) @@ -269,3 +271,38 @@ add_executable(test_cuezero target_link_libraries(test_cuezero PRIVATE Qt6::Core Qt6::Test) target_include_directories(test_cuezero PRIVATE ${CMAKE_SOURCE_DIR}/src) add_test(NAME CueZeroTest COMMAND test_cuezero) + +# --- ensembles + scribble strips --- + +add_executable(test_ensembles + test_ensembles.cpp + ${CMAKE_SOURCE_DIR}/src/core/Ensemble.cpp + ${CMAKE_SOURCE_DIR}/src/core/Show.cpp + ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp + ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp + ${CMAKE_SOURCE_DIR}/src/core/ActorProfile.cpp + ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp + ${CMAKE_SOURCE_DIR}/src/core/ActorProfileLibrary.cpp + ${CMAKE_SOURCE_DIR}/src/core/Position.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueZero.cpp +) +target_link_libraries(test_ensembles PRIVATE Qt6::Core Qt6::Test) +target_include_directories(test_ensembles PRIVATE ${CMAKE_SOURCE_DIR}/src) +add_test(NAME EnsemblesTest COMMAND test_ensembles) + +add_executable(test_scribble + test_scribble.cpp + ${CMAKE_SOURCE_DIR}/src/core/ScribbleController.cpp + ${CMAKE_SOURCE_DIR}/src/core/ActorProfile.cpp + ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp + ${CMAKE_SOURCE_DIR}/src/core/ActorProfileLibrary.cpp + ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp + ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/MixerProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/MixerCapabilities.cpp +) +target_link_libraries(test_scribble PRIVATE Qt6::Core Qt6::Test) +target_include_directories(test_scribble PRIVATE ${CMAKE_SOURCE_DIR}/src) +add_test(NAME ScribbleTest COMMAND test_scribble) diff --git a/tests/test_actor_profiles.cpp b/tests/test_actor_profiles.cpp index 7ed792a..943c1df 100644 --- a/tests/test_actor_profiles.cpp +++ b/tests/test_actor_profiles.cpp @@ -174,7 +174,7 @@ class TestActorProfiles : public QObject { QVERIFY(spy.count() >= 1); const QJsonObject json = show.toJson(); - QCOMPARE(json["version"].toString(), QString("1.2")); + QCOMPARE(json["version"].toString(), QString("1.3")); Show loaded; loaded.fromJson(json); diff --git a/tests/test_ensembles.cpp b/tests/test_ensembles.cpp new file mode 100644 index 0000000..3481ed4 --- /dev/null +++ b/tests/test_ensembles.cpp @@ -0,0 +1,140 @@ +#include "core/Ensemble.h" +#include "core/Show.h" +#include +#include + +using namespace OpenMix; + +class TestEnsembles : public QObject { + Q_OBJECT + + private slots: + // --- Ensemble -------------------------------------------------------- + void ensemble_channels_normalized() { + Ensemble e("Chorus"); + e.setChannels({5, 1, 3, 1, 0, -2}); // unsorted, dupes, non-positive + QCOMPARE(e.channels(), QList({1, 3, 5})); + + e.addChannel(2); + e.addChannel(2); // no dupe + e.addChannel(0); // rejected + QCOMPARE(e.channels(), QList({1, 2, 3, 5})); + + e.removeChannel(3); + QVERIFY(!e.hasChannel(3)); + QVERIFY(e.hasChannel(5)); + } + + void ensemble_defaultSlot_andSetter() { + Ensemble e("A"); + QCOMPARE(e.profileSlot(), QString("Main")); + e.setProfileSlot("Solo"); + QCOMPARE(e.profileSlot(), QString("Solo")); + e.setProfileSlot(""); // empty falls back to default + QCOMPARE(e.profileSlot(), QString("Main")); + } + + void ensemble_roundTrip() { + Ensemble e("Band"); + e.setChannels({9, 4, 4, 7}); + e.setProfileSlot("Solo"); + + Ensemble r = Ensemble::fromJson(e.toJson()); + QCOMPARE(r.id(), e.id()); + QCOMPARE(r.name(), QString("Band")); + QCOMPARE(r.channels(), QList({4, 7, 9})); + QCOMPARE(r.profileSlot(), QString("Solo")); + } + + // --- EnsembleLibrary ------------------------------------------------- + void library_addUpdateRemove_emitsSignals() { + EnsembleLibrary lib; + QSignalSpy added(&lib, &EnsembleLibrary::ensembleAdded); + QSignalSpy modified(&lib, &EnsembleLibrary::ensembleModified); + QSignalSpy removed(&lib, &EnsembleLibrary::ensembleRemoved); + QSignalSpy changed(&lib, &EnsembleLibrary::changed); + + Ensemble e("Cast"); + e.setChannels({1, 2}); + lib.addEnsemble(e); + QCOMPARE(lib.count(), 1); + QCOMPARE(added.count(), 1); + + Ensemble upd = e; + upd.setName("Full Cast"); + lib.updateEnsemble(e.id(), upd); + QCOMPARE(modified.count(), 1); + QCOMPARE(lib.ensembleById(e.id())->name(), QString("Full Cast")); + + lib.removeEnsemble(e.id()); + QCOMPARE(removed.count(), 1); + QCOMPARE(lib.count(), 0); + QVERIFY(changed.count() >= 3); + } + + void library_ensemblesForChannel() { + EnsembleLibrary lib; + Ensemble a("A"); + a.setChannels({1, 2, 3}); + Ensemble b("B"); + b.setChannels({3, 4}); + lib.addEnsemble(a); + lib.addEnsemble(b); + + QCOMPARE(lib.ensemblesForChannel(3).size(), 2); + QCOMPARE(lib.ensemblesForChannel(1).size(), 1); + QCOMPARE(lib.ensemblesForChannel(9).size(), 0); + } + + void library_roundTrips() { + EnsembleLibrary lib; + Ensemble a("Chorus"); + a.setChannels({10, 11, 12}); + a.setProfileSlot("Solo"); + lib.addEnsemble(a); + + EnsembleLibrary other; + other.loadFromJson(lib.toJson()); + QCOMPARE(other.count(), 1); + const Ensemble& r = other.ensembles().first(); + QCOMPARE(r.name(), QString("Chorus")); + QCOMPARE(r.channels(), QList({10, 11, 12})); + QCOMPARE(r.profileSlot(), QString("Solo")); + } + + // --- Show persistence ------------------------------------------------ + void show_persistsEnsembles_andMarksDirty() { + Show show; + QSignalSpy spy(&show, &Show::modifiedChanged); + Ensemble e("Kids"); + e.setChannels({20, 21}); + show.ensembleLibrary()->addEnsemble(e); + QVERIFY(show.isModified()); + QVERIFY(spy.count() >= 1); + + const QJsonObject json = show.toJson(); + QCOMPARE(json["version"].toString(), QString("1.3")); + + Show loaded; + loaded.fromJson(json); + QCOMPARE(loaded.ensembleLibrary()->count(), 1); + QCOMPARE(loaded.ensembleLibrary()->ensembles().first().name(), QString("Kids")); + } + + void show_loadsLegacy_withoutEnsembles() { + QJsonObject legacy; + legacy["version"] = "1.2"; + legacy["name"] = "Legacy"; + legacy["cues"] = QJsonArray(); + + Show show; + show.ensembleLibrary()->addEnsemble(Ensemble("Stale")); + show.fromJson(legacy); + + QCOMPARE(show.ensembleLibrary()->count(), 0); // cleared on load + QVERIFY(!show.isModified()); + } +}; + +QTEST_MAIN(TestEnsembles) +#include "test_ensembles.moc" diff --git a/tests/test_scribble.cpp b/tests/test_scribble.cpp new file mode 100644 index 0000000..fd4c31f --- /dev/null +++ b/tests/test_scribble.cpp @@ -0,0 +1,180 @@ +#include "core/Actor.h" +#include "core/ActorProfileLibrary.h" +#include "core/ChannelMonitor.h" +#include "core/Cue.h" +#include "core/CueList.h" +#include "core/ScribbleController.h" +#include "protocol/MixerProtocol.h" +#include + +using namespace OpenMix; + +// Minimal mixer that records scribble pushes; everything else is a stub. +class RecordingMixer : public MixerProtocol { + public: + QList> nameCalls; + QList> colourCalls; + bool linked = true; + + void setChannelName(int ch, const QString& name) override { nameCalls.append({ch, name}); } + void setChannelColour(int ch, int colour) override { colourCalls.append({ch, colour}); } + + [[nodiscard]] QString protocolName() const override { return "mock"; } + [[nodiscard]] QString protocolDescription() const override { return "mock"; } + bool connect(const QString&, int) override { return true; } + void disconnect() override {} + [[nodiscard]] bool isConnected() const override { return linked; } + [[nodiscard]] QString connectionStatus() const override { return {}; } + [[nodiscard]] ConnectionState connectionState() const override { + return ConnectionState::Connected; + } + void sendParameter(const QString&, const QVariant&) override {} + [[nodiscard]] QVariant getParameter(const QString&) override { return {}; } + void requestParameter(const QString&) override {} + void requestParameterAsync(const QString&, ParameterCallback) override {} + void recallSnapshot(const Cue&) override {} + void recallScene(int) override {} + void refresh() override {} + [[nodiscard]] int latencyMs() const override { return 0; } + + [[nodiscard]] bool hasName(int ch, const QString& name) const { + return nameCalls.contains({ch, name}); + } +}; + +class TestScribble : public QObject { + Q_OBJECT + + private slots: + void refreshNames_pushesActorNames() { + ActorProfileLibrary lib; + lib.addActor(Actor("Alice", 3)); + lib.addActor(Actor("Bob", 5)); + + RecordingMixer mixer; + ScribbleController ctl; + ctl.setActorLibrary(&lib); + ctl.setMixer(&mixer); + + mixer.nameCalls.clear(); + ctl.refreshNames(); + QCOMPARE(mixer.nameCalls.size(), 2); + QVERIFY(mixer.hasName(3, "Alice")); + QVERIFY(mixer.hasName(5, "Bob")); + } + + void refreshNames_prefersLowestOrderActive() { + ActorProfileLibrary lib; + Actor lead("Lead", 4); + lead.setOrder(1); + Actor understudy("Understudy", 4); + understudy.setOrder(2); + lib.addActor(understudy); + lib.addActor(lead); + + RecordingMixer mixer; + ScribbleController ctl; + ctl.setActorLibrary(&lib); + ctl.setMixer(&mixer); + mixer.nameCalls.clear(); + + ctl.refreshNames(); + QCOMPARE(mixer.nameCalls.size(), 1); + QVERIFY(mixer.hasName(4, "Lead")); + } + + void setMixer_refreshesOnlyWhenConnected() { + ActorProfileLibrary lib; + lib.addActor(Actor("Cleo", 1)); + + RecordingMixer offline; + offline.linked = false; + ScribbleController ctl; + ctl.setActorLibrary(&lib); + ctl.setMixer(&offline); + QVERIFY(offline.nameCalls.isEmpty()); // not connected -> no push + + RecordingMixer online; // connected by default + ctl.setMixer(&online); + QVERIFY(online.hasName(1, "Cleo")); // pushed on attach + } + + void channelState_mapsToColour() { + RecordingMixer mixer; + ScribbleController ctl; + ctl.setMixer(&mixer); + mixer.colourCalls.clear(); + + ctl.onChannelStateChanged(7, static_cast(ChannelState::Clipping)); + QCOMPARE(mixer.colourCalls.size(), 1); + QCOMPARE(mixer.colourCalls[0].first, 7); + QCOMPARE(mixer.colourCalls[0].second, ctl.stateColour(ChannelState::Clipping)); + + ctl.onChannelStateChanged(7, static_cast(ChannelState::Silent)); + QCOMPARE(mixer.colourCalls[1].second, ctl.stateColour(ChannelState::Silent)); + } + + void cueNumber_writtenToConfiguredChannel() { + CueList cues; + cues.addCue(Cue(5.0, "Top")); + + RecordingMixer mixer; + ScribbleController ctl; + ctl.setCueList(&cues); + ctl.setMixer(&mixer); + ctl.setCueNumberChannel(10); + mixer.nameCalls.clear(); + + ctl.onCurrentCueChanged(0); + QCOMPARE(mixer.nameCalls.size(), 1); + QCOMPARE(mixer.nameCalls[0].first, 10); + QVERIFY(mixer.nameCalls[0].second.contains("5")); + + // disabled by default (channel 0) -> no cue push + ScribbleController other; + RecordingMixer m2; + other.setCueList(&cues); + other.setMixer(&m2); + m2.nameCalls.clear(); + other.onCurrentCueChanged(0); + QVERIFY(m2.nameCalls.isEmpty()); + } + + void cueChannel_notOverwrittenByActorName() { + ActorProfileLibrary lib; + lib.addActor(Actor("Alice", 3)); + lib.addActor(Actor("Bob", 10)); + + RecordingMixer mixer; + ScribbleController ctl; + ctl.setActorLibrary(&lib); + ctl.setCueNumberChannel(10); // reserved for cue number + ctl.setMixer(&mixer); + mixer.nameCalls.clear(); + + ctl.refreshNames(); + QVERIFY(mixer.hasName(3, "Alice")); + QVERIFY(!mixer.hasName(10, "Bob")); // channel 10 reserved + } + + void disabled_suppressesAllPushes() { + ActorProfileLibrary lib; + lib.addActor(Actor("Alice", 3)); + + RecordingMixer mixer; + ScribbleController ctl; + ctl.setActorLibrary(&lib); + ctl.setMixer(&mixer); + ctl.setEnabled(false); + mixer.nameCalls.clear(); + mixer.colourCalls.clear(); + + ctl.refreshNames(); + ctl.onChannelStateChanged(3, static_cast(ChannelState::Clipping)); + QVERIFY(mixer.nameCalls.isEmpty()); + QVERIFY(mixer.colourCalls.isEmpty()); + } +}; + +QTEST_MAIN(TestScribble) +#include "test_scribble.moc" diff --git a/tests/test_yamaha_scp.cpp b/tests/test_yamaha_scp.cpp index 056d627..dca1502 100644 --- a/tests/test_yamaha_scp.cpp +++ b/tests/test_yamaha_scp.cpp @@ -142,6 +142,14 @@ class TestYamahaScp : public QObject { QByteArray("set MIXER:Current/InCh/Dyna2/Threshold 0 0 -260\n")); } + void buildScribble_nameAndColour() { + ScpProbe p; + QCOMPARE(p.buildChannelName(2, "Vox"), + QByteArray("set MIXER:Current/InCh/Label/Name 2 0 \"Vox\"\n")); + QCOMPARE(p.buildChannelColour(0, 3), + QByteArray("set MIXER:Current/InCh/Label/Color 0 0 3\n")); + } + // ---- incoming line parsing ---- void parseNotify_emitsParameterChanged() { From c6d0a4b9f5060b661614555d2eeae6b135d85838 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Tue, 30 Jun 2026 23:47:11 -0400 Subject: [PATCH 09/92] feat: cue fx mutes, snippets, L/R gang, color/skip, soundcheck mode --- src/core/Cue.cpp | 42 ++++ src/core/Cue.h | 28 +++ src/core/PlaybackEngine.cpp | 103 +++++++-- src/core/PlaybackEngine.h | 24 +++ src/core/Show.cpp | 24 +++ src/core/Show.h | 11 + src/protocol/MixerProtocol.h | 4 + src/protocol/behringer/WingProtocol.cpp | 8 + src/protocol/behringer/WingProtocol.h | 3 + src/protocol/behringer/X32Protocol.cpp | 8 + src/protocol/behringer/X32Protocol.h | 3 + src/ui/CueEditor.cpp | 250 ++++++++++++++++++++++ src/ui/CueEditor.h | 32 +++ src/ui/CueTableModel.cpp | 15 ++ src/ui/CueTableModel.h | 4 +- tests/CMakeLists.txt | 30 +++ tests/test_show_control.cpp | 272 ++++++++++++++++++++++++ 17 files changed, 842 insertions(+), 19 deletions(-) create mode 100644 tests/test_show_control.cpp diff --git a/src/core/Cue.cpp b/src/core/Cue.cpp index 24d75a3..89dfb68 100644 --- a/src/core/Cue.cpp +++ b/src/core/Cue.cpp @@ -308,6 +308,29 @@ QJsonObject Cue::toJson() const { json["channelPositions"] = positionsObj; } + // per-FX-unit mute states: { "": muted } + if (!m_fxMutes.isEmpty()) { + QJsonObject fxObj; + for (auto it = m_fxMutes.constBegin(); it != m_fxMutes.constEnd(); ++it) { + fxObj[QString::number(it.key())] = it.value(); + } + json["fxMutes"] = fxObj; + } + + // console snippets recalled on fire + if (!m_snippets.isEmpty()) { + QJsonArray snippetArray; + for (int snippet : m_snippets) { + snippetArray.append(snippet); + } + json["snippets"] = snippetArray; + } + + if (!m_colour.isEmpty()) { + json["colour"] = m_colour; + } + json["skip"] = m_skip; + return json; } @@ -423,6 +446,25 @@ Cue Cue::fromJson(const QJsonObject& json) { } } + // per-FX-unit mute states + if (json.contains("fxMutes")) { + const QJsonObject fxObj = json["fxMutes"].toObject(); + for (auto it = fxObj.constBegin(); it != fxObj.constEnd(); ++it) { + cue.m_fxMutes[it.key().toInt()] = it.value().toBool(); + } + } + + // console snippets + if (json.contains("snippets")) { + const QJsonArray snippetArray = json["snippets"].toArray(); + for (const QJsonValue& val : snippetArray) { + cue.m_snippets.append(val.toInt()); + } + } + + cue.m_colour = json["colour"].toString(); + cue.m_skip = json["skip"].toBool(false); + return cue; } diff --git a/src/core/Cue.h b/src/core/Cue.h index c34e7ca..48dfc44 100644 --- a/src/core/Cue.h +++ b/src/core/Cue.h @@ -181,6 +181,29 @@ class Cue { void setChannelLevel(int channel, double level) { m_channelLevels[channel] = level; } void removeChannelLevel(int channel) { m_channelLevels.remove(channel); } + // 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; } + void setFxMute(int fxUnit, bool muted) { m_fxMutes[fxUnit] = muted; } + void removeFxMute(int fxUnit) { m_fxMutes.remove(fxUnit); } + + // console snippet indices recalled on fire (partial scene recalls) + [[nodiscard]] QList snippets() const { return m_snippets; } + void setSnippets(const QList& snippets) { m_snippets = snippets; } + void addSnippet(int snippet) { + if (!m_snippets.contains(snippet)) + m_snippets.append(snippet); + } + void removeSnippet(int snippet) { m_snippets.removeAll(snippet); } + + // display colour (hex string, e.g. "#ff0000"); empty = list default + [[nodiscard]] QString colour() const { return m_colour; } + void setColour(const QString& colour) { m_colour = colour; } + + // when true, standby advance (next / auto-advance) steps over this cue + [[nodiscard]] bool skip() const noexcept { return m_skip; } + void setSkip(bool skip) { m_skip = skip; } + QJsonObject toJson() const; [[nodiscard]] static Cue fromJson(const QJsonObject& json); @@ -228,6 +251,11 @@ 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 + QString m_colour; // display colour (hex) + bool m_skip = false; // skip during standby advance }; } // namespace OpenMix diff --git a/src/core/PlaybackEngine.cpp b/src/core/PlaybackEngine.cpp index 93ab764..5f1ad02 100644 --- a/src/core/PlaybackEngine.cpp +++ b/src/core/PlaybackEngine.cpp @@ -35,6 +35,24 @@ void PlaybackEngine::setActorLibrary(ActorProfileLibrary* library) { m_actorLibr void PlaybackEngine::setPositionLibrary(PositionLibrary* library) { m_positionLibrary = library; } +void PlaybackEngine::setChannelGangs(const QList>& gangs) { + m_channelGangs = gangs; + m_gangPartners.clear(); + for (const auto& gang : gangs) { + if (gang.first == gang.second) + continue; + m_gangPartners.insert(gang.first, gang.second); + m_gangPartners.insert(gang.second, gang.first); + } +} + +void PlaybackEngine::setCheckMode(bool enabled) { + if (m_checkMode != enabled) { + m_checkMode = enabled; + emit checkModeChanged(enabled); + } +} + void PlaybackEngine::setValidator(CueValidator* validator) { m_validator = validator; } void PlaybackEngine::setGuard(PlaybackGuard* guard) { m_guard = guard; } @@ -87,7 +105,9 @@ void PlaybackEngine::go() { if (cue.type() == CueType::Snapshot) verifyCue(m_currentIndex, cue); - advanceStandby(); + // in check/soundcheck mode GO holds on the current cue so it can be re-fired + if (!m_checkMode) + advanceStandby(); } void PlaybackEngine::stop() { @@ -141,8 +161,9 @@ void PlaybackEngine::previous() { } void PlaybackEngine::next() { - if (m_cueList && m_standbyIndex < m_cueList->count() - 1) { - setStandbyIndex(m_standbyIndex + 1); + const int idx = nextEnabledIndex(m_standbyIndex); + if (idx >= 0) { + setStandbyIndex(idx); } } @@ -196,11 +217,18 @@ void PlaybackEngine::setStandbyIndex(int index) { void PlaybackEngine::advanceStandby() { if (!m_cueList) return; - if (m_currentIndex + 1 < m_cueList->count()) { - setStandbyIndex(m_currentIndex + 1); - } else { - setStandbyIndex(-1); // end of list + // step over skipped cues; nextEnabledIndex returns -1 at end of list + setStandbyIndex(nextEnabledIndex(m_currentIndex)); +} + +int PlaybackEngine::nextEnabledIndex(int from) const { + if (!m_cueList) + return -1; + for (int i = from + 1; i < m_cueList->count(); ++i) { + if (!m_cueList->at(i).skip()) + return i; } + return -1; } void PlaybackEngine::executeCueInternal(const Cue& cue) { @@ -229,6 +257,12 @@ void PlaybackEngine::executeCueInternal(const Cue& cue) { // apply named-position pan/delay assignments applyChannelPositions(cue); + // send per-FX-unit mute states + applyFxMutes(cue); + + // recall console snippets (partial scene recalls) + recallSnippets(cue); + // apply DCA overrides (mute & label only) applyDCAOverrides(cue, targetDCAs); @@ -314,21 +348,34 @@ void PlaybackEngine::applyChannelLevels(const Cue& cue) { for (auto it = levels.constBegin(); it != levels.constEnd(); ++it) { const int channel = it.key(); const double target = it.value(); - 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, - [this, channel](double v) { - if (m_mixer) - m_mixer->setChannelFader(channel, v); - }); - } else { - m_mixer->setChannelFader(channel, target); + driveChannelLevel(channel, target, fadeMs, curve); + + // mirror to the ganged partner, unless the cue sets it explicitly + const auto partner = m_gangPartners.constFind(channel); + if (partner != m_gangPartners.constEnd() && !levels.contains(partner.value())) { + driveChannelLevel(partner.value(), target, fadeMs, curve); } - m_appliedChannelLevels[channel] = target; } } +void PlaybackEngine::driveChannelLevel(int channel, double target, double fadeMs, FadeCurve curve) { + if (!m_mixer) + return; + + 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, + [this, channel](double v) { + if (m_mixer) + m_mixer->setChannelFader(channel, v); + }); + } else { + m_mixer->setChannelFader(channel, target); + } + m_appliedChannelLevels[channel] = target; +} + void PlaybackEngine::applyChannelPositions(const Cue& cue) { if (!m_mixer || !m_positionLibrary) return; @@ -362,6 +409,26 @@ void PlaybackEngine::applyChannelPositions(const Cue& cue) { } } +void PlaybackEngine::applyFxMutes(const Cue& cue) { + if (!m_mixer) + return; + + // send each FX unit's mute state to the console (documented path /fx/N/mute) + const QMap mutes = cue.fxMutes(); + for (auto it = mutes.constBegin(); it != mutes.constEnd(); ++it) { + m_mixer->sendParameter(QString("/fx/%1/mute").arg(it.key()), it.value() ? 1 : 0); + } +} + +void PlaybackEngine::recallSnippets(const Cue& cue) { + if (!m_mixer) + return; + + for (int snippet : cue.snippets()) { + m_mixer->recallSnippet(snippet); + } +} + void PlaybackEngine::applyVoice(int channel, const VoiceData& voice) { if (voice.gainDb.has_value()) m_mixer->setChannelPreamp(channel, *voice.gainDb); diff --git a/src/core/PlaybackEngine.h b/src/core/PlaybackEngine.h index abe73e0..10d90eb 100644 --- a/src/core/PlaybackEngine.h +++ b/src/core/PlaybackEngine.h @@ -2,9 +2,12 @@ #include "CueValidator.h" #include "FadeEngine.h" +#include #include +#include #include #include +#include #include #include @@ -34,6 +37,16 @@ class PlaybackEngine : public QObject { void setActorLibrary(ActorProfileLibrary* library); void setPositionLibrary(PositionLibrary* library); + // ganged input-channel pairs (from the show); a level applied to one channel + // is mirrored to its partner on fire. + void setChannelGangs(const QList>& gangs); + [[nodiscard]] QList> channelGangs() const { return m_channelGangs; } + + // check / soundcheck (rehearsal) mode: while enabled, GO re-fires the current + // cue instead of advancing standby to the next cue. + void setCheckMode(bool enabled); + [[nodiscard]] bool checkMode() const noexcept { return m_checkMode; } + // drives timed fader fades; exposed so the UI (and tests) can observe/step it [[nodiscard]] FadeEngine* fadeEngine() { return &m_fadeEngine; } @@ -89,6 +102,7 @@ class PlaybackEngine : public QObject { void cueDrifted(int index, const QStringList& paths); void autoFollowArmed(bool armed); void macroChildExecuted(const QString& parentId, const QString& childId); + void checkModeChanged(bool enabled); void cueValidationFailed(int index, const ValidationResult& result); void goLockout(const QString& reason); @@ -101,6 +115,8 @@ class PlaybackEngine : public QObject { void setState(PlaybackState state); void setCurrentIndex(int index); void advanceStandby(); + // first index after 'from' whose cue is not skipped, or -1 at end of list + [[nodiscard]] int nextEnabledIndex(int from) const; void executeCueInternal(const Cue& cue); void applyDCAOverrides(const Cue& cue, const QSet& targetDCAs); void executeMacroCue(const Cue& cue); @@ -119,7 +135,12 @@ class PlaybackEngine : public QObject { void expandChannelProfiles(const Cue& cue); void applyVoice(int channel, const VoiceData& voice); void applyChannelLevels(const Cue& cue); // fade-aware per-channel fader moves + // drive a single channel's fader to target (instant or faded), tracking the + // applied value as the next fade's source. + void driveChannelLevel(int channel, double target, double fadeMs, FadeCurve curve); void applyChannelPositions(const Cue& cue); // named-position pan/delay sends + void applyFxMutes(const Cue& cue); // per-FX-unit mute sends + void recallSnippets(const Cue& cue); // console snippet recalls void verifyCue(int index, const Cue& cue); CueList* m_cueList = nullptr; @@ -130,6 +151,9 @@ class PlaybackEngine : public QObject { FadeEngine m_fadeEngine; QMap m_appliedChannelLevels; // last-applied fader per channel (fade source) + QList> m_channelGangs; // ganged input-channel pairs + QHash m_gangPartners; // channel -> ganged partner (both directions) + bool m_checkMode = false; // soundcheck: GO holds on current cue bool m_verifyCues = false; PlaybackState m_state = PlaybackState::Stopped; int m_currentIndex = -1; diff --git a/src/core/Show.cpp b/src/core/Show.cpp index 72c58ef..a563e99 100644 --- a/src/core/Show.cpp +++ b/src/core/Show.cpp @@ -1,4 +1,5 @@ #include "Show.h" +#include namespace OpenMix { @@ -93,6 +94,7 @@ void Show::newShow() { m_actorProfileLibrary.clear(); m_positionLibrary.clear(); m_cueZero.clear(); + m_channelGangs.clear(); m_isDirty = false; } @@ -108,6 +110,16 @@ QJsonObject Show::toJson() const { json["actors"] = m_actorProfileLibrary.toJson(); json["positions"] = m_positionLibrary.toJson(); json["cueZero"] = m_cueZero.toJson(); + + QJsonArray gangArray; + for (const auto& gang : m_channelGangs) { + QJsonArray pair; + pair.append(gang.first); + pair.append(gang.second); + gangArray.append(pair); + } + json["channelGangs"] = gangArray; + return json; } @@ -150,6 +162,18 @@ void Show::fromJson(const QJsonObject& json) { m_cueZero.clear(); } + // ganged input-channel pairs + m_channelGangs.clear(); + if (json.contains("channelGangs")) { + const QJsonArray gangArray = json["channelGangs"].toArray(); + for (const QJsonValue& val : gangArray) { + const QJsonArray pair = val.toArray(); + if (pair.size() == 2) { + m_channelGangs.append(qMakePair(pair.at(0).toInt(), pair.at(1).toInt())); + } + } + } + m_isDirty = false; emit nameChanged(m_name); } diff --git a/src/core/Show.h b/src/core/Show.h index 885ec07..7107805 100644 --- a/src/core/Show.h +++ b/src/core/Show.h @@ -6,7 +6,9 @@ #include "DCAMapping.h" #include "Position.h" #include +#include #include +#include #include namespace OpenMix { @@ -71,6 +73,14 @@ class Show : public QObject { [[nodiscard]] MixerConfig mixerConfig() const { return m_mixerConfig; } void setMixerConfig(const MixerConfig& config) { m_mixerConfig = config; checkModifiedState(); } + // ganged input-channel pairs; on fire a level applied to one channel is + // mirrored to its partner. Show-level, shared across all cues. + [[nodiscard]] QList> channelGangs() const { return m_channelGangs; } + void setChannelGangs(const QList>& gangs) { + m_channelGangs = gangs; + checkModifiedState(); + } + QJsonObject toJson() const; void fromJson(const QJsonObject& json); @@ -93,6 +103,7 @@ class Show : public QObject { QString m_filePath; CueList m_cueList; MixerConfig m_mixerConfig; + QList> m_channelGangs; // ganged input-channel pairs DCAMapping m_dcaMapping; ActorProfileLibrary m_actorProfileLibrary; PositionLibrary m_positionLibrary; diff --git a/src/protocol/MixerProtocol.h b/src/protocol/MixerProtocol.h index 8825854..b54e4a4 100644 --- a/src/protocol/MixerProtocol.h +++ b/src/protocol/MixerProtocol.h @@ -40,6 +40,10 @@ class MixerProtocol : public QObject { virtual void recallSnapshot(const Cue& cue) = 0; virtual void recallScene(int sceneNumber) = 0; + // recall a console snippet (partial scene) by index. Default no-op so drivers + // opt in; OSC drivers (X32/Wing) override with the console's snippet action. + virtual void recallSnippet(int /*snippet*/) {} + // 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 diff --git a/src/protocol/behringer/WingProtocol.cpp b/src/protocol/behringer/WingProtocol.cpp index fb68538..2a66fe1 100644 --- a/src/protocol/behringer/WingProtocol.cpp +++ b/src/protocol/behringer/WingProtocol.cpp @@ -225,6 +225,14 @@ void WingProtocol::recallScene(int sceneNumber) { m_transport.send("/action/scenes/recall", sceneNumber); } +void WingProtocol::recallSnippet(int snippetNumber) { + if (m_connectionState != ConnectionState::Connected) + return; + + // WING snippet recall + m_transport.send("/-action/gosnippet", snippetNumber); +} + namespace { QString wingChannel(int channel) { return QString("/ch/%1").arg(channel); } diff --git a/src/protocol/behringer/WingProtocol.h b/src/protocol/behringer/WingProtocol.h index 49026c5..364b4a1 100644 --- a/src/protocol/behringer/WingProtocol.h +++ b/src/protocol/behringer/WingProtocol.h @@ -52,6 +52,9 @@ class WingProtocol : public MixerProtocol { // scene recall void recallScene(int sceneNumber) override; + // snippet (partial scene) recall + 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 setChannelMute(int channel, bool muted) override; diff --git a/src/protocol/behringer/X32Protocol.cpp b/src/protocol/behringer/X32Protocol.cpp index 8d42217..a0f017a 100644 --- a/src/protocol/behringer/X32Protocol.cpp +++ b/src/protocol/behringer/X32Protocol.cpp @@ -263,6 +263,14 @@ void X32Protocol::recallScene(int sceneNumber) { m_transport.send("/-action/goscene", sceneNumber); } +void X32Protocol::recallSnippet(int snippetNumber) { + if (m_connectionState != ConnectionState::Connected) + return; + + // X32 snippet recall: /-action/gosnippet followed by snippet number + m_transport.send("/-action/gosnippet", snippetNumber); +} + void X32Protocol::setChannelFader(int channel, double level) { sendParameter(x32Channel(channel) + "/mix/fader", clampUnit(level)); } diff --git a/src/protocol/behringer/X32Protocol.h b/src/protocol/behringer/X32Protocol.h index 021c330..fa7c0f6 100644 --- a/src/protocol/behringer/X32Protocol.h +++ b/src/protocol/behringer/X32Protocol.h @@ -50,6 +50,9 @@ class X32Protocol : public MixerProtocol { // scene/snapshot recall void recallScene(int sceneNumber) override; + // snippet (partial scene) recall + void recallSnippet(int snippetNumber) override; + // semantic channel setters (used by actor-voice recall and fades) void setChannelFader(int channel, double level) override; void setChannelMute(int channel, bool muted) override; diff --git a/src/ui/CueEditor.cpp b/src/ui/CueEditor.cpp index 74d464a..b03d22a 100644 --- a/src/ui/CueEditor.cpp +++ b/src/ui/CueEditor.cpp @@ -10,6 +10,7 @@ #include "theme/Theme.h" #include +#include #include #include #include @@ -41,6 +42,16 @@ CueEditor::CueEditor(Application* app, QWidget* parent) : QWidget(parent), m_app connect(m_actorLibrary, &ActorProfileLibrary::changed, this, &CueEditor::onActorLibraryChanged); } + + // reflect the engine's check-mode state (it is engine-wide, not per-cue) + if (m_app && m_app->playbackEngine()) { + PlaybackEngine* engine = m_app->playbackEngine(); + const QSignalBlocker blocker(m_checkModeCheck); + m_checkModeCheck->setChecked(engine->checkMode()); + connect(engine, &PlaybackEngine::checkModeChanged, this, + &CueEditor::onEngineCheckModeChanged); + } + updateGangsUI(); } void CueEditor::setupUi() { @@ -70,6 +81,19 @@ void CueEditor::setupUi() { m_typeCombo->addItem(tr("Wait"), static_cast(CueType::Wait)); basicLayout->addRow(tr("Type:"), m_typeCombo); + QWidget* colourRow = new QWidget(this); + QHBoxLayout* colourLayout = new QHBoxLayout(colourRow); + colourLayout->setContentsMargins(0, 0, 0, 0); + m_colourEdit = new QLineEdit(colourRow); + m_colourEdit->setPlaceholderText(tr("#rrggbb")); + m_colourPickButton = new QPushButton(tr("Pick..."), colourRow); + colourLayout->addWidget(m_colourEdit); + colourLayout->addWidget(m_colourPickButton); + basicLayout->addRow(tr("Colour:"), colourRow); + + m_skipCheck = new QCheckBox(tr("Skip on standby advance"), this); + basicLayout->addRow(tr("Skip:"), m_skipCheck); + m_mainLayout->addWidget(basicGroup); // timing group @@ -181,6 +205,10 @@ void CueEditor::setupUi() { createChannelProfilesSection(); m_mainLayout->addWidget(m_channelProfilesGroup); + // per-FX-unit mutes + console snippets + createFxMutesSection(); + m_mainLayout->addWidget(m_fxMutesGroup); + // linked QLab (DAW remote) cue QGroupBox* qlabGroup = new QGroupBox(tr("QLab / DAW Remote"), this); QFormLayout* qlabLayout = new QFormLayout(qlabGroup); @@ -190,6 +218,18 @@ void CueEditor::setupUi() { qlabLayout->addRow(tr("QLab cue:"), m_qLabCueEdit); m_mainLayout->addWidget(qlabGroup); + // L/R gangs (show-level) + soundcheck (check) mode toggle + QGroupBox* rehearsalGroup = new QGroupBox(tr("Gangs & Rehearsal"), this); + QFormLayout* rehearsalLayout = new QFormLayout(rehearsalGroup); + m_gangEdit = new QLineEdit(rehearsalGroup); + m_gangEdit->setPlaceholderText(tr("L/R pairs, e.g. 1-2, 3-4")); + m_gangEdit->setToolTip(tr("Ganged channel pairs; a level on one mirrors to its partner")); + rehearsalLayout->addRow(tr("L/R gangs:"), m_gangEdit); + m_checkModeCheck = + new QCheckBox(tr("Soundcheck mode (GO holds on current cue)"), rehearsalGroup); + rehearsalLayout->addRow(tr("Rehearsal:"), m_checkModeCheck); + m_mainLayout->addWidget(rehearsalGroup); + // notes group QGroupBox* notesGroup = new QGroupBox(tr("Notes"), this); QVBoxLayout* notesLayout = new QVBoxLayout(notesGroup); @@ -219,6 +259,54 @@ void CueEditor::setupUi() { connect(m_fadeCurveCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &CueEditor::onFadeCurveChanged); connect(m_qLabCueEdit, &QLineEdit::textChanged, this, &CueEditor::onQLabCueChanged); + + connect(m_colourEdit, &QLineEdit::textChanged, this, &CueEditor::onColourChanged); + connect(m_colourPickButton, &QPushButton::clicked, this, &CueEditor::onColourPick); + connect(m_skipCheck, &QCheckBox::toggled, this, &CueEditor::onSkipChanged); + connect(m_snippetsEdit, &QLineEdit::textChanged, this, &CueEditor::onSnippetsChanged); + connect(m_gangEdit, &QLineEdit::editingFinished, this, &CueEditor::onGangsChanged); + connect(m_checkModeCheck, &QCheckBox::toggled, this, &CueEditor::onCheckModeToggled); +} + +void CueEditor::createFxMutesSection() { + m_fxMutesGroup = new QGroupBox(tr("FX Mutes & Snippets"), this); + QVBoxLayout* layout = new QVBoxLayout(m_fxMutesGroup); + layout->setContentsMargins(4, 4, 4, 4); + + QFormLayout* snippetForm = new QFormLayout(); + m_snippetsEdit = new QLineEdit(m_fxMutesGroup); + m_snippetsEdit->setPlaceholderText(tr("Snippet indices, e.g. 1, 4, 7")); + m_snippetsEdit->setToolTip(tr("Console snippets recalled when this cue fires")); + snippetForm->addRow(tr("Snippets:"), m_snippetsEdit); + layout->addLayout(snippetForm); + + QLabel* hint = + new QLabel(tr("Per FX unit: enable to set its mute state on fire."), m_fxMutesGroup); + hint->setWordWrap(true); + hint->setStyleSheet(QString("color: %1;").arg(Theme::Colors::TextSecondary)); + layout->addWidget(hint); + + QGridLayout* grid = new QGridLayout(); + grid->setContentsMargins(0, 0, 0, 0); + grid->setSpacing(4); + for (int i = 1; i <= 8; ++i) { + FxMuteWidgets widgets; + widgets.enable = new QCheckBox(tr("FX %1").arg(i), m_fxMutesGroup); + widgets.muted = new QCheckBox(tr("Muted"), m_fxMutesGroup); + widgets.muted->setEnabled(false); + + const int row = (i - 1) / 2; + const int col = ((i - 1) % 2) * 2; + grid->addWidget(widgets.enable, row, col); + grid->addWidget(widgets.muted, row, col + 1); + + connect(widgets.enable, &QCheckBox::toggled, widgets.muted, &QCheckBox::setEnabled); + connect(widgets.enable, &QCheckBox::toggled, this, &CueEditor::onFxMuteChanged); + connect(widgets.muted, &QCheckBox::toggled, this, &CueEditor::onFxMuteChanged); + + m_fxMuteWidgets.append(widgets); + } + layout->addLayout(grid); } void CueEditor::createChannelProfilesSection() { @@ -372,6 +460,19 @@ void CueEditor::updateFromCue() { // linked QLab cue m_qLabCueEdit->setText(cue->qLabCue()); + // colour + skip + m_colourEdit->setText(cue->colour()); + m_skipCheck->setChecked(cue->skip()); + + // console snippets + QStringList snippetStrs; + for (int snippet : cue->snippets()) + snippetStrs << QString::number(snippet); + m_snippetsEdit->setText(snippetStrs.join(", ")); + + // per-FX-unit mutes + updateFxMutesUI(); + // per-channel profile + level rebuildChannelTable(); populateChannelTable(); @@ -390,10 +491,17 @@ void CueEditor::updateFromCue() { m_fadeTimeSpin->setValue(0.0); m_fadeCurveCombo->setCurrentIndex(0); m_qLabCueEdit->clear(); + m_colourEdit->clear(); + m_skipCheck->setChecked(false); + m_snippetsEdit->clear(); + updateFxMutesUI(); if (m_channelTable) m_channelTable->setRowCount(0); } + // L/R gangs are show-level, so refresh them regardless of the selected cue + updateGangsUI(); + m_updatingUi = false; } @@ -419,6 +527,33 @@ void CueEditor::updateDCAOverridesUI() { } } +void CueEditor::updateFxMutesUI() { + Cue* cue = currentCue(); + const QMap mutes = cue ? cue->fxMutes() : QMap(); + + for (int i = 0; i < m_fxMuteWidgets.size(); ++i) { + const int fxUnit = i + 1; + const bool set = mutes.contains(fxUnit); + FxMuteWidgets& widgets = m_fxMuteWidgets[i]; + + widgets.enable->setChecked(set); + widgets.muted->setChecked(set && mutes.value(fxUnit)); + widgets.muted->setEnabled(set); + } +} + +void CueEditor::updateGangsUI() { + if (!m_gangEdit || !m_app || !m_app->show()) + return; + + QStringList parts; + for (const auto& gang : m_app->show()->channelGangs()) + parts << QString("%1-%2").arg(gang.first).arg(gang.second); + + const QSignalBlocker blocker(m_gangEdit); + m_gangEdit->setText(parts.join(", ")); +} + void CueEditor::setEnabled(bool enabled) { m_numberSpin->setEnabled(enabled); m_nameEdit->setEnabled(enabled); @@ -432,6 +567,12 @@ void CueEditor::setEnabled(bool enabled) { m_fadeCurveCombo->setEnabled(enabled); m_qLabCueEdit->setEnabled(enabled); m_channelProfilesGroup->setEnabled(enabled); + m_colourEdit->setEnabled(enabled); + m_colourPickButton->setEnabled(enabled); + m_skipCheck->setEnabled(enabled); + m_snippetsEdit->setEnabled(enabled); + m_fxMutesGroup->setEnabled(enabled); + // m_gangEdit and m_checkModeCheck stay enabled: show-/engine-level, not per-cue } void CueEditor::onNumberChanged(double value) { @@ -772,4 +913,113 @@ void CueEditor::onActorLibraryChanged() { m_updatingUi = false; } +void CueEditor::onColourChanged(const QString& text) { + if (m_updatingUi) + return; + Cue* cue = currentCue(); + if (!cue) + return; + cue->setColour(text.trimmed()); + m_app->show()->cueList()->updateCue(m_currentIndex, *cue); + emit cueModified(); +} + +void CueEditor::onColourPick() { + const QColor initial(m_colourEdit->text().trimmed()); + const QColor chosen = QColorDialog::getColor(initial.isValid() ? initial : QColor(Qt::white), + this, tr("Cue Colour")); + if (chosen.isValid()) + m_colourEdit->setText(chosen.name()); // fires onColourChanged +} + +void CueEditor::onSkipChanged(bool checked) { + if (m_updatingUi) + return; + Cue* cue = currentCue(); + if (!cue) + return; + cue->setSkip(checked); + m_app->show()->cueList()->updateCue(m_currentIndex, *cue); + emit cueModified(); +} + +void CueEditor::onSnippetsChanged(const QString& text) { + if (m_updatingUi) + return; + Cue* cue = currentCue(); + if (!cue) + return; + + QList snippets; + const QStringList parts = text.split(',', Qt::SkipEmptyParts); + for (const QString& part : parts) { + bool ok = false; + const int n = part.trimmed().toInt(&ok); + if (ok && !snippets.contains(n)) + snippets.append(n); + } + + cue->setSnippets(snippets); + m_app->show()->cueList()->updateCue(m_currentIndex, *cue); + emit cueModified(); +} + +void CueEditor::onFxMuteChanged() { + if (m_updatingUi) + return; + Cue* cue = currentCue(); + if (!cue) + return; + + QMap mutes; + for (int i = 0; i < m_fxMuteWidgets.size(); ++i) { + if (m_fxMuteWidgets[i].enable->isChecked()) + mutes[i + 1] = m_fxMuteWidgets[i].muted->isChecked(); + } + + cue->setFxMutes(mutes); + m_app->show()->cueList()->updateCue(m_currentIndex, *cue); + emit cueModified(); +} + +void CueEditor::onGangsChanged() { + if (m_updatingUi || !m_app || !m_app->show()) + return; + + QList> gangs; + const QStringList parts = m_gangEdit->text().split(',', Qt::SkipEmptyParts); + for (const QString& part : parts) { + const QStringList nums = part.split('-', Qt::SkipEmptyParts); + if (nums.size() != 2) + continue; + bool ok1 = false; + bool ok2 = false; + const int a = nums[0].trimmed().toInt(&ok1); + const int b = nums[1].trimmed().toInt(&ok2); + if (ok1 && ok2 && a > 0 && b > 0) + gangs.append(qMakePair(a, b)); + } + + m_app->show()->setChannelGangs(gangs); + if (m_app->playbackEngine()) + m_app->playbackEngine()->setChannelGangs(gangs); + + updateGangsUI(); // reflect the normalized form back into the field + emit cueModified(); +} + +void CueEditor::onCheckModeToggled(bool checked) { + if (m_updatingUi) + return; + if (m_app && m_app->playbackEngine()) + m_app->playbackEngine()->setCheckMode(checked); +} + +void CueEditor::onEngineCheckModeChanged(bool enabled) { + if (!m_checkModeCheck || m_checkModeCheck->isChecked() == enabled) + return; + const QSignalBlocker blocker(m_checkModeCheck); + m_checkModeCheck->setChecked(enabled); +} + } // namespace OpenMix diff --git a/src/ui/CueEditor.h b/src/ui/CueEditor.h index d4a0cc9..464fa85 100644 --- a/src/ui/CueEditor.h +++ b/src/ui/CueEditor.h @@ -56,15 +56,27 @@ class CueEditor : public QWidget { void onChannelLevelChanged(int channel); void onActorLibraryChanged(); + void onColourChanged(const QString& text); + void onColourPick(); + void onSkipChanged(bool checked); + void onSnippetsChanged(const QString& text); + void onFxMuteChanged(); + void onGangsChanged(); + void onCheckModeToggled(bool checked); + void onEngineCheckModeChanged(bool enabled); + private: void setupUi(); void createDCATargetingSection(); void createFadeSection(); void createChannelProfilesSection(); + void createFxMutesSection(); void rebuildChannelTable(); void populateChannelTable(); void updateFromCue(); void updateDCAOverridesUI(); + void updateFxMutesUI(); + void updateGangsUI(); void setEnabled(bool enabled); Cue* currentCue(); @@ -89,6 +101,26 @@ class CueEditor : public QWidget { // linked QLab (DAW remote) cue id QLineEdit* m_qLabCueEdit = nullptr; + // per-cue colour + skip flag + QLineEdit* m_colourEdit = nullptr; + QPushButton* m_colourPickButton = nullptr; + QCheckBox* m_skipCheck = nullptr; + + // console snippets recalled on fire (comma-separated indices) + QLineEdit* m_snippetsEdit = nullptr; + + // per-FX-unit mute overrides + struct FxMuteWidgets { + QCheckBox* enable; + QCheckBox* muted; + }; + QGroupBox* m_fxMutesGroup = nullptr; + QVector m_fxMuteWidgets; + + // L/R gangs (show-level) + soundcheck (check) mode toggle + QLineEdit* m_gangEdit = nullptr; + QCheckBox* m_checkModeCheck = nullptr; + // per-channel actor profile slot + level ActorProfileLibrary* m_actorLibrary = nullptr; QGroupBox* m_channelProfilesGroup = nullptr; diff --git a/src/ui/CueTableModel.cpp b/src/ui/CueTableModel.cpp index 754a2d6..5ff946b 100644 --- a/src/ui/CueTableModel.cpp +++ b/src/ui/CueTableModel.cpp @@ -61,9 +61,22 @@ QVariant CueTableModel::data(const QModelIndex& index, int role) const { return cue.tags().join(", "); case ColNotes: return cue.notes(); + case ColColour: + return cue.colour(); } } + // colour swatch shown in the colour column + if (role == Qt::DecorationRole && col == ColColour) { + const QString hex = cue.colour(); + if (!hex.isEmpty()) { + QColor c(hex); + if (c.isValid()) + return c; + } + return QVariant(); + } + if (role == Qt::BackgroundRole) { if (row == m_currentIndex) { return QBrush(QColor(34, 197, 94, 220)); @@ -121,6 +134,8 @@ QVariant CueTableModel::headerData(int section, Qt::Orientation orientation, int return tr("Tags"); case ColNotes: return tr("Notes"); + case ColColour: + return tr("Colour"); } return QVariant(); } diff --git a/src/ui/CueTableModel.h b/src/ui/CueTableModel.h index 9b94692..e858643 100644 --- a/src/ui/CueTableModel.h +++ b/src/ui/CueTableModel.h @@ -12,7 +12,9 @@ class CueTableModel : public QAbstractTableModel { Q_OBJECT public: - enum Column { ColNumber = 0, ColName, ColType, ColGroup, ColTags, ColNotes, ColCount }; + // ColColour is appended last so existing column indices stay stable for views + // that assign per-column delegates/widths by name. + enum Column { ColNumber = 0, ColName, ColType, ColGroup, ColTags, ColNotes, ColColour, ColCount }; explicit CueTableModel(CueList* cueList, QObject* parent = nullptr); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 10df259..a580c75 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -269,3 +269,33 @@ add_executable(test_cuezero target_link_libraries(test_cuezero PRIVATE Qt6::Core Qt6::Test) target_include_directories(test_cuezero PRIVATE ${CMAKE_SOURCE_DIR}/src) add_test(NAME CueZeroTest COMMAND test_cuezero) + +# --- show control: fx mutes, snippets, gangs, colour/skip, check mode --- + +add_executable(test_show_control + test_show_control.cpp + ${CMAKE_SOURCE_DIR}/src/core/PlaybackEngine.cpp + ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp + ${CMAKE_SOURCE_DIR}/src/core/Show.cpp + ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueValidator.cpp + ${CMAKE_SOURCE_DIR}/src/core/PlaybackGuard.cpp + ${CMAKE_SOURCE_DIR}/src/core/PlaybackLogger.cpp + ${CMAKE_SOURCE_DIR}/src/core/ActorProfile.cpp + ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp + ${CMAKE_SOURCE_DIR}/src/core/ActorProfileLibrary.cpp + ${CMAKE_SOURCE_DIR}/src/core/FadeEngine.cpp + ${CMAKE_SOURCE_DIR}/src/core/Position.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueZero.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/MixerProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/MixerCapabilities.cpp +) +target_link_libraries(test_show_control PRIVATE + Qt6::Core + Qt6::Test +) +target_include_directories(test_show_control PRIVATE + ${CMAKE_SOURCE_DIR}/src +) +add_test(NAME ShowControlTest COMMAND test_show_control) diff --git a/tests/test_show_control.cpp b/tests/test_show_control.cpp new file mode 100644 index 0000000..dbb63db --- /dev/null +++ b/tests/test_show_control.cpp @@ -0,0 +1,272 @@ +#include "core/Cue.h" +#include "core/CueList.h" +#include "core/PlaybackEngine.h" +#include "core/Show.h" +#include "protocol/MixerProtocol.h" +#include +#include + +using namespace OpenMix; + +namespace { + +// records every driver call as a readable string so fire behaviour can be +// asserted without real hardware. Always reports Connected once connect() runs. +class RecordingMixer : public MixerProtocol { + public: + explicit RecordingMixer(QObject* parent = nullptr) : MixerProtocol(parent) {} + + QString protocolName() const override { return "recording"; } + QString protocolDescription() const override { return "recording mock"; } + + bool connect(const QString&, int) override { + m_connected = true; + return true; + } + void disconnect() override { m_connected = false; } + bool isConnected() const override { return m_connected; } + QString connectionStatus() const override { return "connected"; } + ConnectionState connectionState() const override { + return m_connected ? ConnectionState::Connected : ConnectionState::Disconnected; + } + + void sendParameter(const QString& path, const QVariant& value) override { + calls << QString("send:%1=%2").arg(path, value.toString()); + } + QVariant getParameter(const QString&) override { return {}; } + void requestParameter(const QString&) override {} + void requestParameterAsync(const QString& path, ParameterCallback cb) override { + if (cb) + cb(path, QVariant(), false); + } + + void recallSnapshot(const Cue&) override {} + 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 { + calls << QString("fader:ch=%1:level=%2").arg(channel).arg(level); + } + + void refresh() override {} + int latencyMs() const override { return 0; } + + QStringList calls; + + private: + bool m_connected = false; +}; + +RecordingMixer* makeConnectedMixer(QObject* parent) { + auto* mixer = new RecordingMixer(parent); + mixer->connect("mock", 0); + return mixer; +} + +} // namespace + +class TestShowControl : public QObject { + Q_OBJECT + + private slots: + // --- serialization --- + + void cue_fxMutes_roundtrip() { + Cue cue(1.0, "A"); + cue.setFxMute(1, true); + cue.setFxMute(4, false); + + Cue loaded = Cue::fromJson(cue.toJson()); + QCOMPARE(loaded.fxMutes().size(), 2); + QCOMPARE(loaded.fxMutes().value(1), true); + QCOMPARE(loaded.fxMutes().value(4), false); + } + + void cue_snippets_roundtrip() { + Cue cue; + cue.setSnippets({3, 7, 9}); + + Cue loaded = Cue::fromJson(cue.toJson()); + QCOMPARE(loaded.snippets(), QList({3, 7, 9})); + } + + void cue_colour_roundtrip() { + Cue cue; + cue.setColour("#ff8800"); + + Cue loaded = Cue::fromJson(cue.toJson()); + QCOMPARE(loaded.colour(), QString("#ff8800")); + } + + void cue_skip_roundtrip() { + Cue cue; + cue.setSkip(true); + QVERIFY(Cue::fromJson(cue.toJson()).skip()); + + Cue defaultCue; + QVERIFY(!Cue::fromJson(defaultCue.toJson()).skip()); + } + + void show_channelGangs_roundtrip() { + Show show; + show.setChannelGangs({qMakePair(1, 2), qMakePair(3, 4)}); + + Show loaded; + loaded.fromJson(show.toJson()); + QCOMPARE(loaded.channelGangs().size(), 2); + QCOMPARE(loaded.channelGangs().at(0), qMakePair(1, 2)); + QCOMPARE(loaded.channelGangs().at(1), qMakePair(3, 4)); + } + + // --- on-fire playback --- + + void fire_sendsFxMutes() { + CueList cues; + Cue cue(1.0, "A"); + cue.setType(CueType::Snapshot); + cue.setFxMute(1, true); + cue.setFxMute(2, false); + cues.addCue(cue); + + RecordingMixer* mixer = makeConnectedMixer(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setMixer(mixer); + engine.executeCue(0); + + QVERIFY2(mixer->calls.contains("send:/fx/1/mute=1"), qPrintable(mixer->calls.join(" | "))); + QVERIFY(mixer->calls.contains("send:/fx/2/mute=0")); + } + + void fire_recallsSnippets() { + CueList cues; + Cue cue(1.0, "A"); + cue.setType(CueType::Snapshot); + cue.addSnippet(3); + cue.addSnippet(7); + cues.addCue(cue); + + RecordingMixer* mixer = makeConnectedMixer(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setMixer(mixer); + engine.executeCue(0); + + QVERIFY(mixer->calls.contains("snippet:3")); + QVERIFY(mixer->calls.contains("snippet:7")); + } + + void fire_gangMirrorsLevelToPartner() { + CueList cues; + Cue cue(1.0, "A"); + cue.setType(CueType::Snapshot); + cue.setChannelLevel(5, 0.4); + cues.addCue(cue); + + RecordingMixer* mixer = makeConnectedMixer(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setMixer(mixer); + engine.setChannelGangs({qMakePair(5, 6)}); + engine.executeCue(0); + + QVERIFY2(mixer->calls.contains("fader:ch=5:level=0.4"), qPrintable(mixer->calls.join(" | "))); + QVERIFY(mixer->calls.contains("fader:ch=6:level=0.4")); + } + + void fire_gangDoesNotOverrideExplicitPartner() { + CueList cues; + Cue cue(1.0, "A"); + cue.setType(CueType::Snapshot); + cue.setChannelLevel(5, 0.4); + cue.setChannelLevel(6, 0.9); // partner has its own explicit level + cues.addCue(cue); + + RecordingMixer* mixer = makeConnectedMixer(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setMixer(mixer); + engine.setChannelGangs({qMakePair(5, 6)}); + engine.executeCue(0); + + QVERIFY(mixer->calls.contains("fader:ch=6:level=0.9")); + QVERIFY(!mixer->calls.contains("fader:ch=6:level=0.4")); + } + + // --- skip + check mode --- + + void advanceStandby_stepsOverSkippedCue() { + CueList cues; + cues.addCue(Cue(1.0, "A")); + Cue skipped(2.0, "B"); + skipped.setSkip(true); + cues.addCue(skipped); + cues.addCue(Cue(3.0, "C")); + + RecordingMixer* mixer = makeConnectedMixer(this); + PlaybackEngine engine; + engine.setCueList(&cues); // standby -> 0 + engine.setMixer(mixer); + + engine.go(); // fire A, advance past skipped B to C + QCOMPARE(engine.currentCueIndex(), 0); + QCOMPARE(engine.standbyCueIndex(), 2); + } + + void next_stepsOverSkippedCue() { + CueList cues; + cues.addCue(Cue(1.0, "A")); + Cue skipped(2.0, "B"); + skipped.setSkip(true); + cues.addCue(skipped); + cues.addCue(Cue(3.0, "C")); + + PlaybackEngine engine; + engine.setCueList(&cues); // standby -> 0 + engine.next(); + QCOMPARE(engine.standbyCueIndex(), 2); + } + + void checkMode_holdsGoOnCurrentCue() { + CueList cues; + cues.addCue(Cue(1.0, "A")); + cues.addCue(Cue(2.0, "B")); + cues.addCue(Cue(3.0, "C")); + + RecordingMixer* mixer = makeConnectedMixer(this); + PlaybackEngine engine; + engine.setCueList(&cues); // standby -> 0 + engine.setMixer(mixer); + engine.setCheckMode(true); + + engine.go(); + QCOMPARE(engine.currentCueIndex(), 0); + QCOMPARE(engine.standbyCueIndex(), 0); // held, not advanced + + engine.go(); + QCOMPARE(engine.currentCueIndex(), 0); // re-fires the same cue + + engine.setCheckMode(false); + engine.go(); // now advances normally + QCOMPARE(engine.standbyCueIndex(), 1); + } + + void checkMode_emitsSignalOnChange() { + PlaybackEngine engine; + QSignalSpy spy(&engine, &PlaybackEngine::checkModeChanged); + + engine.setCheckMode(true); + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(0).toBool(), true); + + engine.setCheckMode(true); // no change + QCOMPARE(spy.count(), 1); + + engine.setCheckMode(false); + QCOMPARE(spy.count(), 2); + QVERIFY(!engine.checkMode()); + } +}; + +QTEST_MAIN(TestShowControl) +#include "test_show_control.moc" From d64a91fdde950316d00a74bb1a7a37b0c5175a02 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 00:02:39 -0400 Subject: [PATCH 10/92] chore: use American spelling (color) --- src/app/Application.cpp | 4 +-- src/app/Application.h | 2 +- src/core/ChannelMonitor.h | 2 +- src/core/Cue.cpp | 6 ++-- src/core/Cue.h | 8 ++--- src/core/PlaybackEngine.cpp | 2 +- src/core/ScribbleController.cpp | 10 +++--- src/core/ScribbleController.h | 12 +++---- src/protocol/MixerProtocol.cpp | 2 +- src/protocol/MixerProtocol.h | 4 +-- src/protocol/behringer/WingProtocol.cpp | 6 ++-- src/protocol/behringer/WingProtocol.h | 2 +- src/protocol/behringer/X32Protocol.cpp | 4 +-- src/protocol/behringer/X32Protocol.h | 2 +- src/protocol/yamaha/YamahaProtocol.cpp | 8 ++--- src/protocol/yamaha/YamahaProtocol.h | 6 ++-- src/ui/CueEditor.cpp | 44 ++++++++++++------------- src/ui/CueEditor.h | 10 +++--- src/ui/CueTableModel.cpp | 14 ++++---- src/ui/CueTableModel.h | 4 +-- tests/CMakeLists.txt | 2 +- tests/test_scribble.cpp | 20 +++++------ tests/test_show_control.cpp | 6 ++-- tests/test_yamaha_scp.cpp | 4 +-- 24 files changed, 92 insertions(+), 92 deletions(-) diff --git a/src/app/Application.cpp b/src/app/Application.cpp index f6dc648..6f0353a 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -211,7 +211,7 @@ void Application::initialize() { m_playbackEngine->go(); }); - // scribble strips: actor names + cue number + silence/clip colouring + // scribble strips: actor names + cue number + silence/clip coloring m_scribbleController->setActorLibrary(m_show->actorProfileLibrary()); m_scribbleController->setCueList(m_show->cueList()); connect(m_show->actorProfileLibrary(), &ActorProfileLibrary::changed, m_scribbleController, @@ -236,7 +236,7 @@ void Application::setupMixerConnection(const QString& type, const QString& host, connect(m_mixer, &MixerProtocol::channelMeter, m_channelMonitor, [this](int channel, float level) { m_channelMonitor->onLevel(channel, level); }); - // scribble strips push actor names/colours to this console + // scribble strips push actor names/colors to this console m_scribbleController->setMixer(m_mixer); m_mixer->connect(host, port); diff --git a/src/app/Application.h b/src/app/Application.h index 94717d6..d47f541 100644 --- a/src/app/Application.h +++ b/src/app/Application.h @@ -78,7 +78,7 @@ class Application : public QObject { [[nodiscard]] TimecodeTriggerList* timecodeTriggers() { return m_timecodeTriggers; } [[nodiscard]] ChannelMonitor* channelMonitor() { return m_channelMonitor; } - // scribble-strip driver (actor names, cue number, silence/clip colours) + // scribble-strip driver (actor names, cue number, silence/clip colors) [[nodiscard]] ScribbleController* scribbleController() { return m_scribbleController; } // mixer connection diff --git a/src/core/ChannelMonitor.h b/src/core/ChannelMonitor.h index 76b8283..a3cac3f 100644 --- a/src/core/ChannelMonitor.h +++ b/src/core/ChannelMonitor.h @@ -7,7 +7,7 @@ class QTimer; namespace OpenMix { -// Per-channel silence/clip state for scribble-strip colouring. +// Per-channel silence/clip state for scribble-strip coloring. enum class ChannelState { Normal = 0, // audio present in band Silent = 1, // below the silence floor for longer than the silence timeout diff --git a/src/core/Cue.cpp b/src/core/Cue.cpp index 89dfb68..f5cb09f 100644 --- a/src/core/Cue.cpp +++ b/src/core/Cue.cpp @@ -326,8 +326,8 @@ QJsonObject Cue::toJson() const { json["snippets"] = snippetArray; } - if (!m_colour.isEmpty()) { - json["colour"] = m_colour; + if (!m_color.isEmpty()) { + json["color"] = m_color; } json["skip"] = m_skip; @@ -462,7 +462,7 @@ Cue Cue::fromJson(const QJsonObject& json) { } } - cue.m_colour = json["colour"].toString(); + cue.m_color = json["color"].toString(); cue.m_skip = json["skip"].toBool(false); return cue; diff --git a/src/core/Cue.h b/src/core/Cue.h index 48dfc44..bda000a 100644 --- a/src/core/Cue.h +++ b/src/core/Cue.h @@ -196,9 +196,9 @@ class Cue { } void removeSnippet(int snippet) { m_snippets.removeAll(snippet); } - // display colour (hex string, e.g. "#ff0000"); empty = list default - [[nodiscard]] QString colour() const { return m_colour; } - void setColour(const QString& colour) { m_colour = colour; } + // display color (hex string, e.g. "#ff0000"); empty = list default + [[nodiscard]] QString color() const { return m_color; } + void setColor(const QString& color) { m_color = color; } // when true, standby advance (next / auto-advance) steps over this cue [[nodiscard]] bool skip() const noexcept { return m_skip; } @@ -254,7 +254,7 @@ class Cue { QMap m_fxMutes; // fx unit index -> muted QList m_snippets; // console snippet indices recalled on fire - QString m_colour; // display colour (hex) + QString m_color; // display color (hex) bool m_skip = false; // skip during standby advance }; diff --git a/src/core/PlaybackEngine.cpp b/src/core/PlaybackEngine.cpp index 5f1ad02..2176cd2 100644 --- a/src/core/PlaybackEngine.cpp +++ b/src/core/PlaybackEngine.cpp @@ -460,7 +460,7 @@ void PlaybackEngine::verifyCue(int index, const Cue& cue) { const QJsonObject params = filterParametersForDCAs(cue, targetDCAs); // only numeric params (faders/levels/toggles) are verifiable by value; - // names/colours are skipped. + // names/colors are skipped. QStringList toVerify; for (auto it = params.begin(); it != params.end(); ++it) { if (it.value().isDouble()) diff --git a/src/core/ScribbleController.cpp b/src/core/ScribbleController.cpp index ccf1fbf..1ab9dd8 100644 --- a/src/core/ScribbleController.cpp +++ b/src/core/ScribbleController.cpp @@ -52,15 +52,15 @@ void ScribbleController::setCueNumberChannel(int channel) { m_cueChannel = channel > 0 ? channel : 0; } -void ScribbleController::setStateColour(ChannelState state, int colour) { +void ScribbleController::setStateColor(ChannelState state, int color) { const int idx = static_cast(state); if (idx >= 0 && idx < 3) - m_stateColours[idx] = colour; + m_stateColors[idx] = color; } -int ScribbleController::stateColour(ChannelState state) const { +int ScribbleController::stateColor(ChannelState state) const { const int idx = static_cast(state); - return (idx >= 0 && idx < 3) ? m_stateColours[idx] : m_stateColours[0]; + return (idx >= 0 && idx < 3) ? m_stateColors[idx] : m_stateColors[0]; } void ScribbleController::refreshNames() { @@ -90,7 +90,7 @@ void ScribbleController::onChannelStateChanged(int channel, int state) { int idx = state; if (idx < 0 || idx >= 3) idx = static_cast(ChannelState::Normal); - m_mixer->setChannelColour(channel, m_stateColours[idx]); + m_mixer->setChannelColor(channel, m_stateColors[idx]); } void ScribbleController::onCurrentCueChanged(int cueIndex) { diff --git a/src/core/ScribbleController.h b/src/core/ScribbleController.h index c7ff27a..2d7dac6 100644 --- a/src/core/ScribbleController.h +++ b/src/core/ScribbleController.h @@ -12,8 +12,8 @@ class CueList; // Drives console scribble strips from show data. It writes each channel's // assigned actor name to that channel's strip, writes the current cue number to -// a configurable strip, and colours strips from ChannelMonitor silence/clip -// state. All pushes go through MixerProtocol::setChannelName / setChannelColour, +// a configurable strip, and colors strips from ChannelMonitor silence/clip +// state. All pushes go through MixerProtocol::setChannelName / setChannelColor, // which are no-ops on drivers that cannot address scribble strips. class ScribbleController : public QObject { Q_OBJECT @@ -33,10 +33,10 @@ class ScribbleController : public QObject { void setCueNumberChannel(int channel); [[nodiscard]] int cueNumberChannel() const noexcept { return m_cueChannel; } - // driver-mapped colour index shown for each monitor state. Defaults suit the + // driver-mapped color index shown for each monitor state. Defaults suit the // X32 palette (white / blue / red); configurable so other consoles fit. - void setStateColour(ChannelState state, int colour); - [[nodiscard]] int stateColour(ChannelState state) const; + void setStateColor(ChannelState state, int color); + [[nodiscard]] int stateColor(ChannelState state) const; // push every assigned actor name to its channel now void refreshNames(); @@ -55,7 +55,7 @@ class ScribbleController : public QObject { int m_cueChannel = 0; // indexed by int(ChannelState): Normal, Silent, Clipping - int m_stateColours[3] = {7, 4, 1}; + int m_stateColors[3] = {7, 4, 1}; }; } // namespace OpenMix diff --git a/src/protocol/MixerProtocol.cpp b/src/protocol/MixerProtocol.cpp index e31001a..8c631c5 100644 --- a/src/protocol/MixerProtocol.cpp +++ b/src/protocol/MixerProtocol.cpp @@ -18,6 +18,6 @@ void MixerProtocol::setChannelEqOn(int, bool) {} void MixerProtocol::setChannelEqBand(int, int, bool, int, double, double, double) {} void MixerProtocol::setChannelDynamics(int, bool, double, double, double, double, double) {} void MixerProtocol::setChannelName(int, const QString&) {} -void MixerProtocol::setChannelColour(int, int) {} +void MixerProtocol::setChannelColor(int, int) {} } // namespace OpenMix diff --git a/src/protocol/MixerProtocol.h b/src/protocol/MixerProtocol.h index cfb2899..f2cb488 100644 --- a/src/protocol/MixerProtocol.h +++ b/src/protocol/MixerProtocol.h @@ -58,10 +58,10 @@ class MixerProtocol : public QObject { virtual void setChannelDynamics(int channel, bool on, double thresholdDb, double ratio, double attackMs, double releaseMs, double makeupDb); - // scribble-strip name and colour. channel is 1-based; colour is a + // scribble-strip name and color. channel is 1-based; color is a // driver-mapped palette index. Default no-op; OSC/SCP drivers override. virtual void setChannelName(int channel, const QString& name); - virtual void setChannelColour(int channel, int colour); + virtual void setChannelColor(int channel, int color); virtual void refresh() = 0; virtual int latencyMs() const = 0; diff --git a/src/protocol/behringer/WingProtocol.cpp b/src/protocol/behringer/WingProtocol.cpp index 33bc148..ed59372 100644 --- a/src/protocol/behringer/WingProtocol.cpp +++ b/src/protocol/behringer/WingProtocol.cpp @@ -311,9 +311,9 @@ void WingProtocol::setChannelName(int channel, const QString& name) { sendParameter(wingChannel(channel) + "/$name", name); } -void WingProtocol::setChannelColour(int channel, int colour) { - // WING channel colour index (best-effort; palette differs from X32) - sendParameter(wingChannel(channel) + "/col", colour); +void WingProtocol::setChannelColor(int channel, int color) { + // WING channel color index (best-effort; palette differs from X32) + sendParameter(wingChannel(channel) + "/col", color); } void WingProtocol::refresh() { diff --git a/src/protocol/behringer/WingProtocol.h b/src/protocol/behringer/WingProtocol.h index 49ddae6..e549d3b 100644 --- a/src/protocol/behringer/WingProtocol.h +++ b/src/protocol/behringer/WingProtocol.h @@ -68,7 +68,7 @@ class WingProtocol : public MixerProtocol { // scribble strips void setChannelName(int channel, const QString& name) override; - void setChannelColour(int channel, int colour) override; + void setChannelColor(int channel, int color) override; // keep-alive void refresh() override; diff --git a/src/protocol/behringer/X32Protocol.cpp b/src/protocol/behringer/X32Protocol.cpp index f650bf9..db7f14f 100644 --- a/src/protocol/behringer/X32Protocol.cpp +++ b/src/protocol/behringer/X32Protocol.cpp @@ -322,9 +322,9 @@ void X32Protocol::setChannelName(int channel, const QString& name) { sendParameter(x32Channel(channel) + "/config/name", name); } -void X32Protocol::setChannelColour(int channel, int colour) { +void X32Protocol::setChannelColor(int channel, int color) { // /config/color is a palette index (0..15) - sendParameter(x32Channel(channel) + "/config/color", colour); + sendParameter(x32Channel(channel) + "/config/color", color); } void X32Protocol::refresh() { diff --git a/src/protocol/behringer/X32Protocol.h b/src/protocol/behringer/X32Protocol.h index ed108a0..7503148 100644 --- a/src/protocol/behringer/X32Protocol.h +++ b/src/protocol/behringer/X32Protocol.h @@ -66,7 +66,7 @@ class X32Protocol : public MixerProtocol { // scribble strips void setChannelName(int channel, const QString& name) override; - void setChannelColour(int channel, int colour) override; + void setChannelColor(int channel, int color) override; // keep-alive void refresh() override; diff --git a/src/protocol/yamaha/YamahaProtocol.cpp b/src/protocol/yamaha/YamahaProtocol.cpp index e0c8808..0875e1c 100644 --- a/src/protocol/yamaha/YamahaProtocol.cpp +++ b/src/protocol/yamaha/YamahaProtocol.cpp @@ -155,8 +155,8 @@ QByteArray YamahaProtocol::buildChannelName(int ch, const QString& name) const { return scpSet(AddrChannelName, ch, 0, name); } -QByteArray YamahaProtocol::buildChannelColour(int ch, int colour) const { - return scpSet(AddrChannelColor, ch, 0, colour); +QByteArray YamahaProtocol::buildChannelColor(int ch, int color) const { + return scpSet(AddrChannelColor, ch, 0, color); } // -------------------------------------------------------------------------- @@ -213,8 +213,8 @@ void YamahaProtocol::setChannelName(int ch, const QString& name) { sendCommand(buildChannelName(ch - 1, name)); } -void YamahaProtocol::setChannelColour(int ch, int colour) { - sendCommand(buildChannelColour(ch - 1, colour)); +void YamahaProtocol::setChannelColor(int ch, int color) { + sendCommand(buildChannelColor(ch - 1, color)); } // -------------------------------------------------------------------------- diff --git a/src/protocol/yamaha/YamahaProtocol.h b/src/protocol/yamaha/YamahaProtocol.h index 6d650e4..60387a0 100644 --- a/src/protocol/yamaha/YamahaProtocol.h +++ b/src/protocol/yamaha/YamahaProtocol.h @@ -101,9 +101,9 @@ class YamahaProtocol : public MixerProtocol { void setChannelDynamics(int ch, bool on, double thresholdDb, double ratio, double attackMs, double releaseMs, double makeupDb) override; - // scribble strips (SCP channel label name + colour index) + // scribble strips (SCP channel label name + color index) void setChannelName(int ch, const QString& name) override; - void setChannelColour(int ch, int colour) override; + void setChannelColor(int ch, int color) override; // --- pure command builders: produce the exact SCP ASCII bytes (LF-terminated). --- [[nodiscard]] static QByteArray scpSet(const QString& address, int idx1, int idx2, int value); @@ -131,7 +131,7 @@ class YamahaProtocol : public MixerProtocol { [[nodiscard]] QByteArray buildChannelEqBandQ(int ch, int band, double q) const; [[nodiscard]] QByteArray buildChannelDynamicsThreshold(int ch, double thresholdDb) const; [[nodiscard]] QByteArray buildChannelName(int ch, const QString& name) const; - [[nodiscard]] QByteArray buildChannelColour(int ch, int colour) const; + [[nodiscard]] QByteArray buildChannelColor(int ch, int color) const; protected: // Head-amp (preamp) gain scaling. CL/QL/Rivage transmit centi-dB; the DM7 diff --git a/src/ui/CueEditor.cpp b/src/ui/CueEditor.cpp index b03d22a..9752a2f 100644 --- a/src/ui/CueEditor.cpp +++ b/src/ui/CueEditor.cpp @@ -81,15 +81,15 @@ void CueEditor::setupUi() { m_typeCombo->addItem(tr("Wait"), static_cast(CueType::Wait)); basicLayout->addRow(tr("Type:"), m_typeCombo); - QWidget* colourRow = new QWidget(this); - QHBoxLayout* colourLayout = new QHBoxLayout(colourRow); - colourLayout->setContentsMargins(0, 0, 0, 0); - m_colourEdit = new QLineEdit(colourRow); - m_colourEdit->setPlaceholderText(tr("#rrggbb")); - m_colourPickButton = new QPushButton(tr("Pick..."), colourRow); - colourLayout->addWidget(m_colourEdit); - colourLayout->addWidget(m_colourPickButton); - basicLayout->addRow(tr("Colour:"), colourRow); + QWidget* colorRow = new QWidget(this); + QHBoxLayout* colorLayout = new QHBoxLayout(colorRow); + colorLayout->setContentsMargins(0, 0, 0, 0); + m_colorEdit = new QLineEdit(colorRow); + m_colorEdit->setPlaceholderText(tr("#rrggbb")); + m_colorPickButton = new QPushButton(tr("Pick..."), colorRow); + colorLayout->addWidget(m_colorEdit); + colorLayout->addWidget(m_colorPickButton); + basicLayout->addRow(tr("Color:"), colorRow); m_skipCheck = new QCheckBox(tr("Skip on standby advance"), this); basicLayout->addRow(tr("Skip:"), m_skipCheck); @@ -260,8 +260,8 @@ void CueEditor::setupUi() { &CueEditor::onFadeCurveChanged); connect(m_qLabCueEdit, &QLineEdit::textChanged, this, &CueEditor::onQLabCueChanged); - connect(m_colourEdit, &QLineEdit::textChanged, this, &CueEditor::onColourChanged); - connect(m_colourPickButton, &QPushButton::clicked, this, &CueEditor::onColourPick); + connect(m_colorEdit, &QLineEdit::textChanged, this, &CueEditor::onColorChanged); + connect(m_colorPickButton, &QPushButton::clicked, this, &CueEditor::onColorPick); connect(m_skipCheck, &QCheckBox::toggled, this, &CueEditor::onSkipChanged); connect(m_snippetsEdit, &QLineEdit::textChanged, this, &CueEditor::onSnippetsChanged); connect(m_gangEdit, &QLineEdit::editingFinished, this, &CueEditor::onGangsChanged); @@ -460,8 +460,8 @@ void CueEditor::updateFromCue() { // linked QLab cue m_qLabCueEdit->setText(cue->qLabCue()); - // colour + skip - m_colourEdit->setText(cue->colour()); + // color + skip + m_colorEdit->setText(cue->color()); m_skipCheck->setChecked(cue->skip()); // console snippets @@ -491,7 +491,7 @@ void CueEditor::updateFromCue() { m_fadeTimeSpin->setValue(0.0); m_fadeCurveCombo->setCurrentIndex(0); m_qLabCueEdit->clear(); - m_colourEdit->clear(); + m_colorEdit->clear(); m_skipCheck->setChecked(false); m_snippetsEdit->clear(); updateFxMutesUI(); @@ -567,8 +567,8 @@ void CueEditor::setEnabled(bool enabled) { m_fadeCurveCombo->setEnabled(enabled); m_qLabCueEdit->setEnabled(enabled); m_channelProfilesGroup->setEnabled(enabled); - m_colourEdit->setEnabled(enabled); - m_colourPickButton->setEnabled(enabled); + m_colorEdit->setEnabled(enabled); + m_colorPickButton->setEnabled(enabled); m_skipCheck->setEnabled(enabled); m_snippetsEdit->setEnabled(enabled); m_fxMutesGroup->setEnabled(enabled); @@ -913,23 +913,23 @@ void CueEditor::onActorLibraryChanged() { m_updatingUi = false; } -void CueEditor::onColourChanged(const QString& text) { +void CueEditor::onColorChanged(const QString& text) { if (m_updatingUi) return; Cue* cue = currentCue(); if (!cue) return; - cue->setColour(text.trimmed()); + cue->setColor(text.trimmed()); m_app->show()->cueList()->updateCue(m_currentIndex, *cue); emit cueModified(); } -void CueEditor::onColourPick() { - const QColor initial(m_colourEdit->text().trimmed()); +void CueEditor::onColorPick() { + const QColor initial(m_colorEdit->text().trimmed()); const QColor chosen = QColorDialog::getColor(initial.isValid() ? initial : QColor(Qt::white), - this, tr("Cue Colour")); + this, tr("Cue Color")); if (chosen.isValid()) - m_colourEdit->setText(chosen.name()); // fires onColourChanged + m_colorEdit->setText(chosen.name()); // fires onColorChanged } void CueEditor::onSkipChanged(bool checked) { diff --git a/src/ui/CueEditor.h b/src/ui/CueEditor.h index 464fa85..cb11af4 100644 --- a/src/ui/CueEditor.h +++ b/src/ui/CueEditor.h @@ -56,8 +56,8 @@ class CueEditor : public QWidget { void onChannelLevelChanged(int channel); void onActorLibraryChanged(); - void onColourChanged(const QString& text); - void onColourPick(); + void onColorChanged(const QString& text); + void onColorPick(); void onSkipChanged(bool checked); void onSnippetsChanged(const QString& text); void onFxMuteChanged(); @@ -101,9 +101,9 @@ class CueEditor : public QWidget { // linked QLab (DAW remote) cue id QLineEdit* m_qLabCueEdit = nullptr; - // per-cue colour + skip flag - QLineEdit* m_colourEdit = nullptr; - QPushButton* m_colourPickButton = nullptr; + // per-cue color + skip flag + QLineEdit* m_colorEdit = nullptr; + QPushButton* m_colorPickButton = nullptr; QCheckBox* m_skipCheck = nullptr; // console snippets recalled on fire (comma-separated indices) diff --git a/src/ui/CueTableModel.cpp b/src/ui/CueTableModel.cpp index 5ff946b..39db903 100644 --- a/src/ui/CueTableModel.cpp +++ b/src/ui/CueTableModel.cpp @@ -61,14 +61,14 @@ QVariant CueTableModel::data(const QModelIndex& index, int role) const { return cue.tags().join(", "); case ColNotes: return cue.notes(); - case ColColour: - return cue.colour(); + case ColColor: + return cue.color(); } } - // colour swatch shown in the colour column - if (role == Qt::DecorationRole && col == ColColour) { - const QString hex = cue.colour(); + // color swatch shown in the color column + if (role == Qt::DecorationRole && col == ColColor) { + const QString hex = cue.color(); if (!hex.isEmpty()) { QColor c(hex); if (c.isValid()) @@ -134,8 +134,8 @@ QVariant CueTableModel::headerData(int section, Qt::Orientation orientation, int return tr("Tags"); case ColNotes: return tr("Notes"); - case ColColour: - return tr("Colour"); + case ColColor: + return tr("Color"); } return QVariant(); } diff --git a/src/ui/CueTableModel.h b/src/ui/CueTableModel.h index e858643..73d68f4 100644 --- a/src/ui/CueTableModel.h +++ b/src/ui/CueTableModel.h @@ -12,9 +12,9 @@ class CueTableModel : public QAbstractTableModel { Q_OBJECT public: - // ColColour is appended last so existing column indices stay stable for views + // ColColor is appended last so existing column indices stay stable for views // that assign per-column delegates/widths by name. - enum Column { ColNumber = 0, ColName, ColType, ColGroup, ColTags, ColNotes, ColColour, ColCount }; + enum Column { ColNumber = 0, ColName, ColType, ColGroup, ColTags, ColNotes, ColColor, ColCount }; explicit CueTableModel(CueList* cueList, QObject* parent = nullptr); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5e1a8ff..c9d73bc 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -307,7 +307,7 @@ target_link_libraries(test_scribble PRIVATE Qt6::Core Qt6::Test) target_include_directories(test_scribble PRIVATE ${CMAKE_SOURCE_DIR}/src) add_test(NAME ScribbleTest COMMAND test_scribble) -# --- fx mutes, snippets, gangs, colour/skip, check mode --- +# --- fx mutes, snippets, gangs, color/skip, check mode --- add_executable(test_show_control test_show_control.cpp diff --git a/tests/test_scribble.cpp b/tests/test_scribble.cpp index fd4c31f..36f33ce 100644 --- a/tests/test_scribble.cpp +++ b/tests/test_scribble.cpp @@ -13,11 +13,11 @@ using namespace OpenMix; class RecordingMixer : public MixerProtocol { public: QList> nameCalls; - QList> colourCalls; + QList> colorCalls; bool linked = true; void setChannelName(int ch, const QString& name) override { nameCalls.append({ch, name}); } - void setChannelColour(int ch, int colour) override { colourCalls.append({ch, colour}); } + void setChannelColor(int ch, int color) override { colorCalls.append({ch, color}); } [[nodiscard]] QString protocolName() const override { return "mock"; } [[nodiscard]] QString protocolDescription() const override { return "mock"; } @@ -99,19 +99,19 @@ class TestScribble : public QObject { QVERIFY(online.hasName(1, "Cleo")); // pushed on attach } - void channelState_mapsToColour() { + void channelState_mapsToColor() { RecordingMixer mixer; ScribbleController ctl; ctl.setMixer(&mixer); - mixer.colourCalls.clear(); + mixer.colorCalls.clear(); ctl.onChannelStateChanged(7, static_cast(ChannelState::Clipping)); - QCOMPARE(mixer.colourCalls.size(), 1); - QCOMPARE(mixer.colourCalls[0].first, 7); - QCOMPARE(mixer.colourCalls[0].second, ctl.stateColour(ChannelState::Clipping)); + QCOMPARE(mixer.colorCalls.size(), 1); + QCOMPARE(mixer.colorCalls[0].first, 7); + QCOMPARE(mixer.colorCalls[0].second, ctl.stateColor(ChannelState::Clipping)); ctl.onChannelStateChanged(7, static_cast(ChannelState::Silent)); - QCOMPARE(mixer.colourCalls[1].second, ctl.stateColour(ChannelState::Silent)); + QCOMPARE(mixer.colorCalls[1].second, ctl.stateColor(ChannelState::Silent)); } void cueNumber_writtenToConfiguredChannel() { @@ -167,12 +167,12 @@ class TestScribble : public QObject { ctl.setMixer(&mixer); ctl.setEnabled(false); mixer.nameCalls.clear(); - mixer.colourCalls.clear(); + mixer.colorCalls.clear(); ctl.refreshNames(); ctl.onChannelStateChanged(3, static_cast(ChannelState::Clipping)); QVERIFY(mixer.nameCalls.isEmpty()); - QVERIFY(mixer.colourCalls.isEmpty()); + QVERIFY(mixer.colorCalls.isEmpty()); } }; diff --git a/tests/test_show_control.cpp b/tests/test_show_control.cpp index dbb63db..8e361b4 100644 --- a/tests/test_show_control.cpp +++ b/tests/test_show_control.cpp @@ -90,12 +90,12 @@ class TestShowControl : public QObject { QCOMPARE(loaded.snippets(), QList({3, 7, 9})); } - void cue_colour_roundtrip() { + void cue_color_roundtrip() { Cue cue; - cue.setColour("#ff8800"); + cue.setColor("#ff8800"); Cue loaded = Cue::fromJson(cue.toJson()); - QCOMPARE(loaded.colour(), QString("#ff8800")); + QCOMPARE(loaded.color(), QString("#ff8800")); } void cue_skip_roundtrip() { diff --git a/tests/test_yamaha_scp.cpp b/tests/test_yamaha_scp.cpp index dca1502..d71bb83 100644 --- a/tests/test_yamaha_scp.cpp +++ b/tests/test_yamaha_scp.cpp @@ -142,11 +142,11 @@ class TestYamahaScp : public QObject { QByteArray("set MIXER:Current/InCh/Dyna2/Threshold 0 0 -260\n")); } - void buildScribble_nameAndColour() { + void buildScribble_nameAndColor() { ScpProbe p; QCOMPARE(p.buildChannelName(2, "Vox"), QByteArray("set MIXER:Current/InCh/Label/Name 2 0 \"Vox\"\n")); - QCOMPARE(p.buildChannelColour(0, 3), + QCOMPARE(p.buildChannelColor(0, 3), QByteArray("set MIXER:Current/InCh/Label/Color 0 0 3\n")); } From 040126ff9715e09bdcb88d552ddf11ea980a4b9f Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 00:25:53 -0400 Subject: [PATCH 11/92] feat: console-workflow refinements + Settings dialog (highlight, mute buttons, console toggles, QLab passcode) --- CMakeLists.txt | 2 + src/app/Application.cpp | 41 ++++++- src/app/QLabClient.cpp | 28 +++++ src/app/QLabClient.h | 16 +++ src/core/PlaybackEngine.cpp | 19 +++- src/core/PlaybackEngine.h | 19 ++++ src/core/ScribbleController.cpp | 95 ++++++++++++++-- src/core/ScribbleController.h | 22 ++++ src/core/Show.cpp | 78 ++++++++++++- src/core/Show.h | 36 +++++- src/midi/MidiControlMapping.cpp | 18 +++ src/midi/MidiControlMapping.h | 14 +++ src/midi/MidiInputManager.cpp | 40 +++++++ src/midi/MidiInputManager.h | 8 ++ src/ui/MainWindow.cpp | 14 +++ src/ui/MainWindow.h | 2 + src/ui/SettingsDialog.cpp | 191 ++++++++++++++++++++++++++++++++ src/ui/SettingsDialog.h | 61 ++++++++++ tests/CMakeLists.txt | 8 ++ tests/test_actor_profiles.cpp | 2 +- tests/test_ensembles.cpp | 2 +- tests/test_midi_mute.cpp | 50 +++++++++ tests/test_qlab_client.cpp | 50 +++++++++ tests/test_scribble.cpp | 117 +++++++++++++++++++ tests/test_show_control.cpp | 131 ++++++++++++++++++++++ 25 files changed, 1043 insertions(+), 21 deletions(-) create mode 100644 src/ui/SettingsDialog.cpp create mode 100644 src/ui/SettingsDialog.h create mode 100644 tests/test_midi_mute.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1adb4d0..5abd244 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -121,6 +121,7 @@ set(SOURCES src/ui/ActorSetupPanel.cpp src/ui/EnsemblePanel.cpp src/ui/RemoteControlDialog.cpp + src/ui/SettingsDialog.cpp src/ui/PopOutWindow.cpp src/ui/BubbleButton.cpp src/ui/BubbleBar.cpp @@ -218,6 +219,7 @@ set(HEADERS src/ui/ActorGroupIo.h src/ui/EnsemblePanel.h src/ui/RemoteControlDialog.h + src/ui/SettingsDialog.h src/ui/PopOutWindow.h src/ui/BubbleButton.h src/ui/BubbleBar.h diff --git a/src/app/Application.cpp b/src/app/Application.cpp index 6f0353a..3c3a59a 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -106,10 +106,16 @@ void Application::initialize() { m_playbackEngine->setActorLibrary(m_show->actorProfileLibrary()); m_playbackEngine->setPositionLibrary(m_show->positionLibrary()); m_playbackEngine->setChannelGangs(m_show->channelGangs()); - - // re-seed L/R gangs whenever a project is loaded (fromJson re-emits nameChanged) - connect(m_show, &Show::nameChanged, this, - [this]() { m_playbackEngine->setChannelGangs(m_show->channelGangs()); }); + m_playbackEngine->setDimDcaFaders(m_show->dimDcaFaders()); + m_playbackEngine->setMuteDcaUnassign(m_show->muteDcaUnassign()); + + // re-seed gangs + console-behavior toggles whenever a project is loaded + // (fromJson re-emits nameChanged) + connect(m_show, &Show::nameChanged, this, [this]() { + m_playbackEngine->setChannelGangs(m_show->channelGangs()); + m_playbackEngine->setDimDcaFaders(m_show->dimDcaFaders()); + m_playbackEngine->setMuteDcaUnassign(m_show->muteDcaUnassign()); + }); if (m_mixer) { m_playbackEngine->setMixer(m_mixer); @@ -211,15 +217,40 @@ void Application::initialize() { m_playbackEngine->go(); }); - // scribble strips: actor names + cue number + silence/clip coloring + // scribble strips: actor names + cue number + silence/clip coloring + + // active-channel highlight (DCA mapping resolves a cue's touched channels) m_scribbleController->setActorLibrary(m_show->actorProfileLibrary()); m_scribbleController->setCueList(m_show->cueList()); + m_scribbleController->setDCAMapping(m_show->dcaMapping()); connect(m_show->actorProfileLibrary(), &ActorProfileLibrary::changed, m_scribbleController, &ScribbleController::onActorLibraryChanged); connect(m_channelMonitor, &ChannelMonitor::channelStateChanged, m_scribbleController, &ScribbleController::onChannelStateChanged); connect(m_playbackEngine, &PlaybackEngine::currentCueChanged, m_scribbleController, &ScribbleController::onCurrentCueChanged); + + // seed scribble-highlight + channel-monitor tunables persisted by the + // Settings dialog + { + QSettings settings; + settings.beginGroup("Scribble"); + m_scribbleController->setHighlightColor( + settings.value("highlightColor", m_scribbleController->highlightColor()).toInt()); + m_scribbleController->setHighlightEnabled( + settings.value("highlightEnabled", false).toBool()); + settings.endGroup(); + + settings.beginGroup("Monitor"); + m_channelMonitor->setSilenceThreshold( + settings.value("silenceThreshold", m_channelMonitor->silenceThreshold()).toDouble()); + m_channelMonitor->setClipThreshold( + settings.value("clipThreshold", m_channelMonitor->clipThreshold()).toDouble()); + m_channelMonitor->setSilenceTimeoutMs( + settings.value("silenceTimeoutMs", m_channelMonitor->silenceTimeoutMs()).toInt()); + m_channelMonitor->setClipHoldMs( + settings.value("clipHoldMs", m_channelMonitor->clipHoldMs()).toInt()); + settings.endGroup(); + } } void Application::setupMixerConnection(const QString& type, const QString& host, int port) { diff --git a/src/app/QLabClient.cpp b/src/app/QLabClient.cpp index 86eb137..4edb914 100644 --- a/src/app/QLabClient.cpp +++ b/src/app/QLabClient.cpp @@ -16,9 +16,17 @@ QLabClient::~QLabClient() { void QLabClient::setTarget(const QString& host, int port) { m_host = host; m_port = port > 0 ? port : QLAB_DEFAULT_PORT; + m_connected = false; // re-authenticate against the new target rebuildAddress(); } +void QLabClient::setPasscode(const QString& passcode) { + if (m_passcode == passcode) + return; + m_passcode = passcode; + m_connected = false; // resend /connect with the new passcode +} + void QLabClient::rebuildAddress() { if (m_address) { lo_address_free(m_address); @@ -32,9 +40,18 @@ QString QLabClient::prefix() const { return m_workspaceId.isEmpty() ? QString() : QStringLiteral("/workspace/") + m_workspaceId; } +void QLabClient::ensureConnected() { + if (m_connected || m_passcode.isEmpty() || !m_address) + return; + lo_send(m_address, (prefix() + QStringLiteral("/connect")).toUtf8().constData(), "s", + m_passcode.toUtf8().constData()); + m_connected = true; +} + void QLabClient::send(const QString& address) { if (!m_enabled || !m_address) return; + ensureConnected(); lo_send(m_address, address.toUtf8().constData(), ""); emit sent(address); } @@ -53,6 +70,12 @@ void QLabClient::triggerCue(const QString& cueId) { void QLabClient::go() { send(prefix() + QStringLiteral("/go")); } +void QLabClient::back() { + if (m_suppressBack) + return; + send(prefix() + QStringLiteral("/playhead/previous")); +} + void QLabClient::loadFromSettings() { QSettings settings; settings.beginGroup("QLab"); @@ -61,6 +84,9 @@ void QLabClient::loadFromSettings() { m_enabled = settings.value("enabled", false).toBool(); m_preRollMs = settings.value("preRollMs", 0).toInt(); m_workspaceId = settings.value("workspaceId").toString(); + m_passcode = settings.value("passcode").toString(); + m_suppressBack = settings.value("suppressBack", false).toBool(); + m_connected = false; settings.endGroup(); rebuildAddress(); } @@ -73,6 +99,8 @@ void QLabClient::saveToSettings() { settings.setValue("enabled", m_enabled); settings.setValue("preRollMs", m_preRollMs); settings.setValue("workspaceId", m_workspaceId); + settings.setValue("passcode", m_passcode); + settings.setValue("suppressBack", m_suppressBack); settings.endGroup(); } diff --git a/src/app/QLabClient.h b/src/app/QLabClient.h index 011db73..d059178 100644 --- a/src/app/QLabClient.h +++ b/src/app/QLabClient.h @@ -31,6 +31,16 @@ class QLabClient : public QObject { void setWorkspaceId(const QString& id) { m_workspaceId = id; } [[nodiscard]] QString workspaceId() const { return m_workspaceId; } + // workspace passcode; when set, a /connect is sent (once per target) before + // the first command so a locked QLab workspace accepts remote control. + void setPasscode(const QString& passcode); + [[nodiscard]] QString passcode() const { return m_passcode; } + + // when true, back() is suppressed so our transport never rewinds QLab's + // playhead (some rigs drive QLab forward-only from the console). + void setSuppressBack(bool suppress) { m_suppressBack = suppress; } + [[nodiscard]] bool suppressBack() const { return m_suppressBack; } + void loadFromSettings(); void saveToSettings(); @@ -39,6 +49,8 @@ class QLabClient : public QObject { void triggerCue(const QString& cueId); // GO on the QLab workspace void go(); + // step the QLab playhead back one cue (no-op when suppressBack is set) + void back(); signals: void sent(const QString& address); @@ -46,6 +58,7 @@ class QLabClient : public QObject { private: void rebuildAddress(); void send(const QString& address); + void ensureConnected(); // send /connect with the passcode once per target [[nodiscard]] QString prefix() const; // "" or "/workspace/" lo_address m_address = nullptr; @@ -54,6 +67,9 @@ class QLabClient : public QObject { bool m_enabled = false; int m_preRollMs = 0; QString m_workspaceId; + QString m_passcode; + bool m_suppressBack = false; + bool m_connected = false; // passcode already sent for the current target }; } // namespace OpenMix diff --git a/src/core/PlaybackEngine.cpp b/src/core/PlaybackEngine.cpp index 2176cd2..fff8f27 100644 --- a/src/core/PlaybackEngine.cpp +++ b/src/core/PlaybackEngine.cpp @@ -110,6 +110,16 @@ void PlaybackEngine::go() { advanceStandby(); } +void PlaybackEngine::toggleChannelMute(int channel) { + if (channel <= 0) + return; + const bool muted = !m_channelMutes.value(channel, false); + m_channelMutes[channel] = muted; + if (m_mixer) + m_mixer->setChannelMute(channel, muted); + emit channelMuteChanged(channel, muted); +} + void PlaybackEngine::stop() { m_autoFollowTimer.stop(); m_autoFollowArmed = false; @@ -304,8 +314,15 @@ void PlaybackEngine::applyDCAOverrides(const Cue& cue, const QSet& targetDC DCAOverride override = cue.dcaOverride(dca); if (override.mute.has_value()) { + const bool muting = override.mute.value(); QString path = QString("/dca/%1/mute").arg(dca); - m_mixer->sendParameter(path, override.mute.value() ? 1 : 0); + m_mixer->sendParameter(path, muting ? 1 : 0); + + // optional console behaviors, only on the muting edge + if (muting && m_dimDcaFaders) + m_mixer->sendParameter(QString("/dca/%1/fader").arg(dca), 0.0); + if (muting && m_muteDcaUnassign) + m_mixer->sendParameter(QString("/dca/%1/assign").arg(dca), 0); } if (override.label.has_value()) { diff --git a/src/core/PlaybackEngine.h b/src/core/PlaybackEngine.h index 10d90eb..75a5a95 100644 --- a/src/core/PlaybackEngine.h +++ b/src/core/PlaybackEngine.h @@ -55,6 +55,18 @@ class PlaybackEngine : public QObject { void setVerifyCues(bool enabled) { m_verifyCues = enabled; } [[nodiscard]] bool verifyCues() const noexcept { return m_verifyCues; } + // console-behavior toggles applied during DCA override recall (mirrors show + // preferences; both default off so existing recalls are unchanged): + // dimDcaFaders - pull a DCA's fader to unity-off when a cue mutes it + // muteDcaUnassign - drop a DCA's channel assignments when a cue mutes it + void setDimDcaFaders(bool enabled) { m_dimDcaFaders = enabled; } + [[nodiscard]] bool dimDcaFaders() const noexcept { return m_dimDcaFaders; } + void setMuteDcaUnassign(bool enabled) { m_muteDcaUnassign = enabled; } + [[nodiscard]] bool muteDcaUnassign() const noexcept { return m_muteDcaUnassign; } + + // channel mute state driven by MIDI mute buttons (console-style mute toggle) + [[nodiscard]] bool isChannelMuted(int channel) const { return m_channelMutes.value(channel); } + void setValidator(CueValidator* validator); [[nodiscard]] CueValidator* validator() const noexcept { return m_validator; } @@ -81,6 +93,9 @@ class PlaybackEngine : public QObject { void go(); void stop(); + // toggle a channel's mute on the console and remember the new state + void toggleChannelMute(int channel); + void goToFirst(); void goToLast(); void goToIndex(int index); @@ -107,6 +122,7 @@ class PlaybackEngine : public QObject { void cueValidationFailed(int index, const ValidationResult& result); void goLockout(const QString& reason); void emergencyStopped(); + void channelMuteChanged(int channel, bool muted); private slots: void onAutoFollowTimerTimeout(); @@ -155,6 +171,9 @@ class PlaybackEngine : public QObject { QHash m_gangPartners; // channel -> ganged partner (both directions) bool m_checkMode = false; // soundcheck: GO holds on current cue bool m_verifyCues = false; + bool m_dimDcaFaders = false; // dim a DCA's fader when a cue mutes it + bool m_muteDcaUnassign = false; // unassign a DCA's channels when muted + QHash m_channelMutes; // channel -> mute state (MIDI mute buttons) PlaybackState m_state = PlaybackState::Stopped; int m_currentIndex = -1; int m_standbyIndex = -1; diff --git a/src/core/ScribbleController.cpp b/src/core/ScribbleController.cpp index 1ab9dd8..ce44b03 100644 --- a/src/core/ScribbleController.cpp +++ b/src/core/ScribbleController.cpp @@ -4,6 +4,7 @@ #include "ActorProfileLibrary.h" #include "Cue.h" #include "CueList.h" +#include "DCAMapping.h" #include "protocol/MixerProtocol.h" #include @@ -48,6 +49,22 @@ void ScribbleController::setEnabled(bool enabled) { refreshNames(); } +void ScribbleController::setHighlightEnabled(bool enabled) { + if (m_highlightEnabled == enabled) + return; + m_highlightEnabled = enabled; + + if (m_highlightEnabled) { + updateHighlight(m_currentCueIndex); + } else if (m_mixer) { + // restore every highlighted strip to the Normal state color + const int normal = m_stateColors[static_cast(ChannelState::Normal)]; + for (int ch : m_highlightedChannels) + m_mixer->setChannelColor(ch, normal); + m_highlightedChannels.clear(); + } +} + void ScribbleController::setCueNumberChannel(int channel) { m_cueChannel = channel > 0 ? channel : 0; } @@ -90,20 +107,84 @@ void ScribbleController::onChannelStateChanged(int channel, int state) { int idx = state; if (idx < 0 || idx >= 3) idx = static_cast(ChannelState::Normal); - m_mixer->setChannelColor(channel, m_stateColors[idx]); + + int color = m_stateColors[idx]; + // a highlighted channel keeps its highlight while it reads Normal; silence + // and clip warnings still win so the operator sees them. + if (idx == static_cast(ChannelState::Normal) && m_highlightEnabled && + m_highlightedChannels.contains(channel)) { + color = m_highlightColor; + } + m_mixer->setChannelColor(channel, color); } void ScribbleController::onCurrentCueChanged(int cueIndex) { - if (!m_enabled || !m_mixer || m_cueChannel <= 0) - return; + m_currentCueIndex = cueIndex; - QString label = QStringLiteral("--"); - if (m_cueList && cueIndex >= 0 && cueIndex < m_cueList->count()) - label = cueLabel(m_cueList->at(cueIndex).number()); + if (m_enabled && m_mixer && m_cueChannel > 0) { + QString label = QStringLiteral("--"); + if (m_cueList && cueIndex >= 0 && cueIndex < m_cueList->count()) + label = cueLabel(m_cueList->at(cueIndex).number()); + m_mixer->setChannelName(m_cueChannel, label); + } - m_mixer->setChannelName(m_cueChannel, label); + updateHighlight(cueIndex); } void ScribbleController::onActorLibraryChanged() { refreshNames(); } +QSet ScribbleController::channelsTouchedByCue(const Cue& cue) const { + QSet channels; + + for (int ch : cue.channelProfiles().keys()) + if (ch > 0) + channels.insert(ch); + for (int ch : cue.channelLevels().keys()) + if (ch > 0) + channels.insert(ch); + + // channels reached through the cue's targeted DCAs (per-cue custom mapping + // if present, otherwise the show-level mapping) + const QSet targetDCAs = cue.targetedDCAs(); // empty = all DCAs + if (cue.hasCustomDCAMapping()) { + const QMap> mapping = cue.dcaChannelMapping(); + for (auto it = mapping.constBegin(); it != mapping.constEnd(); ++it) { + if (!targetDCAs.isEmpty() && !targetDCAs.contains(it.key())) + continue; + for (int ch : it.value()) + if (ch > 0) + channels.insert(ch); + } + } else if (m_dcaMapping) { + const QSet dcas = targetDCAs.isEmpty() ? m_dcaMapping->assignedDCAs() : targetDCAs; + for (int dca : dcas) + for (int ch : m_dcaMapping->channelsForDCA(dca)) + if (ch > 0) + channels.insert(ch); + } + + return channels; +} + +void ScribbleController::updateHighlight(int cueIndex) { + if (!m_highlightEnabled || !m_enabled || !m_mixer) + return; + + QSet touched; + if (m_cueList && cueIndex >= 0 && cueIndex < m_cueList->count()) + touched = channelsTouchedByCue(m_cueList->at(cueIndex)); + + // restore strips that are no longer touched to the Normal state color + const int normal = m_stateColors[static_cast(ChannelState::Normal)]; + for (int ch : m_highlightedChannels) { + if (!touched.contains(ch)) + m_mixer->setChannelColor(ch, normal); + } + + for (int ch : touched) + m_mixer->setChannelColor(ch, m_highlightColor); + + m_highlightedChannels = touched; +} + } // namespace OpenMix diff --git a/src/core/ScribbleController.h b/src/core/ScribbleController.h index 2d7dac6..88b434a 100644 --- a/src/core/ScribbleController.h +++ b/src/core/ScribbleController.h @@ -2,6 +2,7 @@ #include "ChannelMonitor.h" // ChannelState #include +#include #include namespace OpenMix { @@ -9,6 +10,8 @@ namespace OpenMix { class MixerProtocol; class ActorProfileLibrary; class CueList; +class DCAMapping; +class Cue; // Drives console scribble strips from show data. It writes each channel's // assigned actor name to that channel's strip, writes the current cue number to @@ -24,10 +27,19 @@ class ScribbleController : public QObject { void setMixer(MixerProtocol* mixer); void setActorLibrary(ActorProfileLibrary* library); void setCueList(CueList* cueList); + void setDCAMapping(DCAMapping* mapping) { m_dcaMapping = mapping; } void setEnabled(bool enabled); [[nodiscard]] bool isEnabled() const noexcept { return m_enabled; } + // active-channel highlight: color the strips of channels the current cue + // touches (its actor-profile / level channels + its DCA-assigned channels) + // and restore the rest, so the operator sees which faders a cue moves. + void setHighlightEnabled(bool enabled); + [[nodiscard]] bool isHighlightEnabled() const noexcept { return m_highlightEnabled; } + void setHighlightColor(int color) { m_highlightColor = color; } + [[nodiscard]] int highlightColor() const noexcept { return m_highlightColor; } + // strip that shows the current cue number; 0 disables (no strip is used and // actor names are never suppressed). 1-based. void setCueNumberChannel(int channel); @@ -47,15 +59,25 @@ class ScribbleController : public QObject { void onActorLibraryChanged(); private: + // channels the given cue touches (actor-profile + level + DCA-assigned) + [[nodiscard]] QSet channelsTouchedByCue(const Cue& cue) const; + void updateHighlight(int cueIndex); + MixerProtocol* m_mixer = nullptr; ActorProfileLibrary* m_library = nullptr; CueList* m_cueList = nullptr; + DCAMapping* m_dcaMapping = nullptr; bool m_enabled = true; int m_cueChannel = 0; // indexed by int(ChannelState): Normal, Silent, Clipping int m_stateColors[3] = {7, 4, 1}; + + bool m_highlightEnabled = false; + int m_highlightColor = 2; // driver palette index (X32: green) + QSet m_highlightedChannels; // channels currently highlighted + int m_currentCueIndex = -1; }; } // namespace OpenMix diff --git a/src/core/Show.cpp b/src/core/Show.cpp index 4cfaf18..43cad10 100644 --- a/src/core/Show.cpp +++ b/src/core/Show.cpp @@ -86,11 +86,51 @@ void Show::connectCueZeroSignals() { connect(&m_cueZero, &CueZero::changed, this, &Show::checkModifiedState); } +void Show::setChannelGangs(const QList>& gangs) { + m_channelGangs = gangs; + // keep per-gang metadata aligned by index, preserving existing entries + while (m_channelGangMeta.size() > gangs.size()) + m_channelGangMeta.removeLast(); + while (m_channelGangMeta.size() < gangs.size()) + m_channelGangMeta.append(qMakePair(QString(), QString())); + checkModifiedState(); +} + +void Show::setGangName(int index, const QString& name) { + if (index < 0 || index >= m_channelGangMeta.size()) + return; + m_channelGangMeta[index].first = name; + checkModifiedState(); +} + +QString Show::gangName(int index) const { + return (index >= 0 && index < m_channelGangMeta.size()) ? m_channelGangMeta[index].first + : QString(); +} + +void Show::setGangColor(int index, const QString& color) { + if (index < 0 || index >= m_channelGangMeta.size()) + return; + m_channelGangMeta[index].second = color; + checkModifiedState(); +} + +QString Show::gangColor(int index) const { + return (index >= 0 && index < m_channelGangMeta.size()) ? m_channelGangMeta[index].second + : QString(); +} + void Show::newShow() { m_name = tr("Untitled Show"); m_author.clear(); + m_designer.clear(); m_notes.clear(); m_filePath.clear(); + m_dimDcaFaders = false; + m_selectOnSpill = false; + m_muteDcaUnassign = false; + m_suppressBackupSwitch = false; + m_channelGangMeta.clear(); m_mixerConfig = MixerConfig(); m_mixerConfig.type = "x32"; m_mixerConfig.port = 10023; @@ -106,11 +146,17 @@ void Show::newShow() { QJsonObject Show::toJson() const { QJsonObject json; - json["version"] = "1.3"; + json["version"] = "1.4"; json["name"] = m_name; json["author"] = m_author; + json["designer"] = m_designer; json["notes"] = m_notes; json["mixer"] = m_mixerConfig.toJson(); + + json["dimDcaFaders"] = m_dimDcaFaders; + json["selectOnSpill"] = m_selectOnSpill; + json["muteDcaUnassign"] = m_muteDcaUnassign; + json["suppressBackupSwitch"] = m_suppressBackupSwitch; json["cues"] = m_cueList.toJson(); json["dcaMapping"] = m_dcaMapping.toJson(); json["actors"] = m_actorProfileLibrary.toJson(); @@ -127,6 +173,16 @@ QJsonObject Show::toJson() const { } json["channelGangs"] = gangArray; + // per-gang name/color, aligned by index with channelGangs + QJsonArray gangMetaArray; + for (const auto& meta : m_channelGangMeta) { + QJsonObject obj; + obj["name"] = meta.first; + obj["color"] = meta.second; + gangMetaArray.append(obj); + } + json["channelGangMeta"] = gangMetaArray; + return json; } @@ -138,10 +194,17 @@ void Show::fromJson(const QJsonObject& json) { m_name = json["name"].toString("Untitled Show"); m_author = json["author"].toString(); + m_designer = json["designer"].toString(); m_notes = json["notes"].toString(); m_mixerConfig = MixerConfig::fromJson(json["mixer"].toObject()); m_cueList.fromJson(json["cues"].toArray()); + // console-behavior preferences (added in show version 1.4) + m_dimDcaFaders = json["dimDcaFaders"].toBool(false); + m_selectOnSpill = json["selectOnSpill"].toBool(false); + m_muteDcaUnassign = json["muteDcaUnassign"].toBool(false); + m_suppressBackupSwitch = json["suppressBackupSwitch"].toBool(false); + if (json.contains("dcaMapping")) { m_dcaMapping.loadFromJson(json["dcaMapping"].toObject()); } else { @@ -188,6 +251,19 @@ void Show::fromJson(const QJsonObject& json) { } } + // per-gang name/color, aligned by index (added in show version 1.4) + m_channelGangMeta.clear(); + const QJsonArray gangMetaArray = json["channelGangMeta"].toArray(); + for (const QJsonValue& val : gangMetaArray) { + const QJsonObject obj = val.toObject(); + m_channelGangMeta.append(qMakePair(obj["name"].toString(), obj["color"].toString())); + } + // keep metadata length aligned with the gang list + while (m_channelGangMeta.size() < m_channelGangs.size()) + m_channelGangMeta.append(qMakePair(QString(), QString())); + while (m_channelGangMeta.size() > m_channelGangs.size()) + m_channelGangMeta.removeLast(); + m_isDirty = false; emit nameChanged(m_name); } diff --git a/src/core/Show.h b/src/core/Show.h index d39361f..4f3a91f 100644 --- a/src/core/Show.h +++ b/src/core/Show.h @@ -41,6 +41,9 @@ class Show : public QObject { [[nodiscard]] QString author() const { return m_author; } 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(); } + [[nodiscard]] QString notes() const { return m_notes; } void setNotes(const QString& notes) { m_notes = notes; checkModifiedState(); } @@ -80,10 +83,25 @@ class Show : public QObject { // ganged input-channel pairs; on fire a level applied to one channel is // mirrored to its partner. Show-level, shared across all cues. [[nodiscard]] QList> channelGangs() const { return m_channelGangs; } - void setChannelGangs(const QList>& gangs) { - m_channelGangs = gangs; - checkModifiedState(); - } + void setChannelGangs(const QList>& gangs); + + // optional per-gang label/color, aligned by gang index; cosmetic only. + void setGangName(int index, const QString& name); + [[nodiscard]] QString gangName(int index) const; + void setGangColor(int index, const QString& color); + [[nodiscard]] QString gangColor(int index) const; + + // console-behavior preferences. muteDcaUnassign and dimDcaFaders drive real + // 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(); } + [[nodiscard]] bool selectOnSpill() const noexcept { return m_selectOnSpill; } + void setSelectOnSpill(bool on) { m_selectOnSpill = on; checkModifiedState(); } + [[nodiscard]] bool muteDcaUnassign() const noexcept { return m_muteDcaUnassign; } + void setMuteDcaUnassign(bool on) { m_muteDcaUnassign = on; checkModifiedState(); } + [[nodiscard]] bool suppressBackupSwitch() const noexcept { return m_suppressBackupSwitch; } + void setSuppressBackupSwitch(bool on) { m_suppressBackupSwitch = on; checkModifiedState(); } QJsonObject toJson() const; void fromJson(const QJsonObject& json); @@ -104,11 +122,19 @@ class Show : public QObject { QString m_name; QString m_author; + QString m_designer; QString m_notes; 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; + bool m_selectOnSpill = false; + bool m_muteDcaUnassign = false; + bool m_suppressBackupSwitch = false; + DCAMapping m_dcaMapping; ActorProfileLibrary m_actorProfileLibrary; PositionLibrary m_positionLibrary; diff --git a/src/midi/MidiControlMapping.cpp b/src/midi/MidiControlMapping.cpp index 3e5800d..0e79bc1 100644 --- a/src/midi/MidiControlMapping.cpp +++ b/src/midi/MidiControlMapping.cpp @@ -109,6 +109,24 @@ MidiMapping MidiMapping::fromJson(const QJsonObject& json) { return mapping; } +bool MidiMuteAssignment::operator==(const MidiMuteAssignment& other) const { + return trigger == other.trigger && channel == other.channel; +} + +QJsonObject MidiMuteAssignment::toJson() const { + QJsonObject obj; + obj["trigger"] = trigger.toJson(); + obj["channel"] = channel; + return obj; +} + +MidiMuteAssignment MidiMuteAssignment::fromJson(const QJsonObject& json) { + MidiMuteAssignment mute; + mute.trigger = MidiTrigger::fromJson(json["trigger"].toObject()); + mute.channel = json["channel"].toInt(0); + return mute; +} + QString midiMessageTypeToString(MidiMessageType type) { switch (type) { case MidiMessageType::NoteOn: diff --git a/src/midi/MidiControlMapping.h b/src/midi/MidiControlMapping.h index feeb91c..0b37b9f 100644 --- a/src/midi/MidiControlMapping.h +++ b/src/midi/MidiControlMapping.h @@ -53,6 +53,20 @@ struct MidiMapping { static MidiMapping fromJson(const QJsonObject& json); }; +// Binds a MIDI trigger to a mixer input channel; firing it toggles that +// channel's mute (a console-style mute button). Kept separate from MidiMapping +// so the parameterless action mappings stay untouched. +struct MidiMuteAssignment { + MidiTrigger trigger; + int channel = 0; // 1-based mixer input channel + + bool operator==(const MidiMuteAssignment& other) const; + bool operator!=(const MidiMuteAssignment& other) const { return !(*this == other); } + + QJsonObject toJson() const; + static MidiMuteAssignment fromJson(const QJsonObject& json); +}; + QString midiMessageTypeToString(MidiMessageType type); MidiMessageType midiMessageTypeFromString(const QString& str); diff --git a/src/midi/MidiInputManager.cpp b/src/midi/MidiInputManager.cpp index 332e6a8..acf7db2 100644 --- a/src/midi/MidiInputManager.cpp +++ b/src/midi/MidiInputManager.cpp @@ -128,6 +128,21 @@ void MidiInputManager::clearMappings() { emit mappingsChanged(); } +void MidiInputManager::setMuteAssignments(const QVector& assignments) { + m_muteAssignments = assignments; + emit mappingsChanged(); +} + +void MidiInputManager::addMuteAssignment(const MidiMuteAssignment& assignment) { + m_muteAssignments.append(assignment); + emit mappingsChanged(); +} + +void MidiInputManager::clearMuteAssignments() { + m_muteAssignments.clear(); + emit mappingsChanged(); +} + bool MidiInputManager::hasConflict(const MidiTrigger& trigger, int excludeIndex) const { for (int i = 0; i < m_mappings.size(); ++i) { if (i == excludeIndex) @@ -157,6 +172,12 @@ void MidiInputManager::saveToSettings() { QJsonDocument doc(mappingsArray); settings.setValue("mappings", doc.toJson(QJsonDocument::Compact)); + QJsonArray muteArray; + for (const MidiMuteAssignment& mute : m_muteAssignments) { + muteArray.append(mute.toJson()); + } + settings.setValue("muteAssignments", QJsonDocument(muteArray).toJson(QJsonDocument::Compact)); + settings.endGroup(); } @@ -178,6 +199,15 @@ void MidiInputManager::loadFromSettings() { } } + QString muteJson = settings.value("muteAssignments").toString(); + if (!muteJson.isEmpty()) { + QJsonArray muteArray = QJsonDocument::fromJson(muteJson.toUtf8()).array(); + m_muteAssignments.clear(); + for (const QJsonValue& val : muteArray) { + m_muteAssignments.append(MidiMuteAssignment::fromJson(val.toObject())); + } + } + settings.endGroup(); // try to open saved device @@ -314,6 +344,16 @@ void MidiInputManager::processMidiMessage(const std::vector& mess break; // only execute first matching mapping } } + + // channel mute buttons: toggle the assigned channel's mute on the console + for (const MidiMuteAssignment& mute : m_muteAssignments) { + if (mute.channel > 0 && mute.trigger.matches(type, channel, noteOrCC, value)) { + if (m_engine) + m_engine->toggleChannelMute(mute.channel); + emit channelMuteToggled(mute.channel); + break; + } + } } void MidiInputManager::handleSysex(const std::vector& message) { diff --git a/src/midi/MidiInputManager.h b/src/midi/MidiInputManager.h index 21ecb75..dc4d3c9 100644 --- a/src/midi/MidiInputManager.h +++ b/src/midi/MidiInputManager.h @@ -59,6 +59,12 @@ class MidiInputManager : public QObject { void clearMappings(); bool hasConflict(const MidiTrigger& trigger, int excludeIndex = -1) const; + // channel mute-button assignments (trigger -> channel mute toggle) + void setMuteAssignments(const QVector& assignments); + QVector muteAssignments() const { return m_muteAssignments; } + void addMuteAssignment(const MidiMuteAssignment& assignment); + void clearMuteAssignments(); + // MIDI Learn mode void startMidiLearn(); void stopMidiLearn(); @@ -77,6 +83,7 @@ class MidiInputManager : public QObject { void midiMessageReceived(MidiMessageType type, int channel, int noteOrCC, int value); void midiLearnReceived(const MidiTrigger& trigger); void actionExecuted(MidiAction action); + void channelMuteToggled(int channel); // a mute-button assignment fired void mscReceived(int command); // a MIDI Show Control command was handled // a full MIDI Time Code frame was assembled (from quarter-frame or full-frame) void timecodeChanged(int hours, int minutes, int seconds, int frames); @@ -105,6 +112,7 @@ class MidiInputManager : public QObject { int m_mscDeviceId = 0x7F; // 0x7F = respond to all device IDs QVector m_mappings; + QVector m_muteAssignments; bool m_midiLearnActive = false; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index cd9b68e..3dfe715 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -12,6 +12,7 @@ #include "MixerFeedbackPanel.h" #include "PopOutWindow.h" #include "RemoteControlDialog.h" +#include "SettingsDialog.h" #include "app/Application.h" #include "core/AppLogger.h" #include "core/CueList.h" @@ -224,6 +225,11 @@ void MainWindow::createActions() { m_remoteControlAction->setToolTip(tr("Configure MSC, inbound OSC, and QLab remote control")); connect(m_remoteControlAction, &QAction::triggered, this, &MainWindow::showRemoteControlDialog); + m_appSettingsAction = new QAction(tr("Settings..."), this); + m_appSettingsAction->setToolTip( + tr("Console behavior, scribble highlight, channel monitor, and QLab settings")); + connect(m_appSettingsAction, &QAction::triggered, this, &MainWindow::showSettingsDialog); + // help actions m_aboutAction = new QAction(tr("&About OpenMix"), this); m_aboutAction->setToolTip(tr("About this application")); @@ -277,6 +283,7 @@ void MainWindow::registerShortcuts() { sm->registerAction("settings.keyboardShortcuts", m_keyboardShortcutsAction, QKeySequence()); sm->registerAction("settings.midiController", m_midiControllerAction, QKeySequence()); sm->registerAction("settings.remoteControl", m_remoteControlAction, QKeySequence()); + sm->registerAction("settings.appSettings", m_appSettingsAction, QKeySequence()); // help actions sm->registerAction("help.about", m_aboutAction, QKeySequence()); @@ -326,6 +333,8 @@ void MainWindow::createMenus() { m_settingsMenu->addAction(m_keyboardShortcutsAction); m_settingsMenu->addAction(m_midiControllerAction); m_settingsMenu->addAction(m_remoteControlAction); + m_settingsMenu->addSeparator(); + m_settingsMenu->addAction(m_appSettingsAction); m_helpMenu = menuBar()->addMenu(tr("&Help")); m_helpMenu->addAction(m_aboutAction); @@ -896,6 +905,11 @@ void MainWindow::showRemoteControlDialog() { dialog.exec(); } +void MainWindow::showSettingsDialog() { + SettingsDialog dialog(m_app, this); + dialog.exec(); +} + void MainWindow::showKeyboardShortcutsDialog() { KeyboardShortcutsDialog dialog(m_app->shortcutManager(), this); dialog.exec(); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index e5e175d..11dc21a 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -81,6 +81,7 @@ class MainWindow : public QMainWindow { void showMidiConfigDialog(); void showRemoteControlDialog(); void showKeyboardShortcutsDialog(); + void showSettingsDialog(); void showLogViewerDialog(); // bubble bar interaction @@ -174,6 +175,7 @@ class MainWindow : public QMainWindow { QAction* m_keyboardShortcutsAction; QAction* m_midiControllerAction; QAction* m_remoteControlAction; + QAction* m_appSettingsAction; // help actions QAction* m_aboutAction; diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp new file mode 100644 index 0000000..213b97d --- /dev/null +++ b/src/ui/SettingsDialog.cpp @@ -0,0 +1,191 @@ +#include "SettingsDialog.h" +#include "app/Application.h" +#include "app/QLabClient.h" +#include "core/ChannelMonitor.h" +#include "core/PlaybackEngine.h" +#include "core/ScribbleController.h" +#include "core/Show.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace OpenMix { + +SettingsDialog::SettingsDialog(Application* app, QWidget* parent) : QDialog(parent), m_app(app) { + setWindowTitle(tr("Settings")); + setupUi(); + loadValues(); +} + +void SettingsDialog::setupUi() { + auto* layout = new QVBoxLayout(this); + + // --- console behavior -------------------------------------------------- + auto* consoleBox = new QGroupBox(tr("Console Behavior"), this); + auto* consoleForm = new QFormLayout(consoleBox); + m_dimDcaFaders = new QCheckBox(tr("Dim a DCA's fader when a cue mutes it"), consoleBox); + m_muteDcaUnassign = new QCheckBox(tr("Unassign a DCA's channels when a cue mutes it"), + consoleBox); + m_selectOnSpill = new QCheckBox(tr("Select channel on spill"), consoleBox); + m_suppressBackupSwitch = new QCheckBox(tr("Suppress backup-switch prompt"), consoleBox); + consoleForm->addRow(m_dimDcaFaders); + consoleForm->addRow(m_muteDcaUnassign); + consoleForm->addRow(m_selectOnSpill); + consoleForm->addRow(m_suppressBackupSwitch); + layout->addWidget(consoleBox); + + // --- show metadata ----------------------------------------------------- + auto* metaBox = new QGroupBox(tr("Show Metadata"), this); + auto* metaForm = new QFormLayout(metaBox); + m_designer = new QLineEdit(metaBox); + m_designer->setPlaceholderText(tr("Sound designer")); + metaForm->addRow(tr("Designer:"), m_designer); + layout->addWidget(metaBox); + + // --- scribble highlight ------------------------------------------------ + auto* scribbleBox = new QGroupBox(tr("Active-Channel Highlight"), this); + auto* scribbleForm = new QFormLayout(scribbleBox); + m_highlightEnabled = new QCheckBox(tr("Highlight the channels the current cue touches"), + scribbleBox); + m_highlightColor = new QSpinBox(scribbleBox); + m_highlightColor->setRange(0, 15); + m_highlightColor->setToolTip(tr("Driver-mapped scribble-strip palette index")); + scribbleForm->addRow(m_highlightEnabled); + scribbleForm->addRow(tr("Highlight color:"), m_highlightColor); + layout->addWidget(scribbleBox); + + // --- channel monitor --------------------------------------------------- + auto* monitorBox = new QGroupBox(tr("Channel Monitor"), this); + auto* monitorForm = new QFormLayout(monitorBox); + m_silenceThreshold = new QDoubleSpinBox(monitorBox); + m_silenceThreshold->setRange(0.0, 1.0); + m_silenceThreshold->setSingleStep(0.01); + m_silenceThreshold->setDecimals(3); + m_clipThreshold = new QDoubleSpinBox(monitorBox); + m_clipThreshold->setRange(0.0, 1.0); + m_clipThreshold->setSingleStep(0.01); + m_clipThreshold->setDecimals(3); + m_silenceTimeoutMs = new QSpinBox(monitorBox); + m_silenceTimeoutMs->setRange(0, 60000); + m_silenceTimeoutMs->setSingleStep(500); + m_silenceTimeoutMs->setSuffix(tr(" ms")); + m_clipHoldMs = new QSpinBox(monitorBox); + m_clipHoldMs->setRange(0, 60000); + m_clipHoldMs->setSingleStep(500); + m_clipHoldMs->setSuffix(tr(" ms")); + monitorForm->addRow(tr("Silence floor:"), m_silenceThreshold); + monitorForm->addRow(tr("Clip ceiling:"), m_clipThreshold); + monitorForm->addRow(tr("Silence timeout:"), m_silenceTimeoutMs); + monitorForm->addRow(tr("Clip hold:"), m_clipHoldMs); + layout->addWidget(monitorBox); + + // --- QLab remote ------------------------------------------------------- + auto* qlabBox = new QGroupBox(tr("QLab Remote"), this); + auto* qlabForm = new QFormLayout(qlabBox); + m_qlabPasscode = new QLineEdit(qlabBox); + m_qlabPasscode->setEchoMode(QLineEdit::Password); + m_qlabPasscode->setPlaceholderText(tr("(workspace passcode)")); + m_qlabSuppressBack = new QCheckBox(tr("Never rewind the QLab playhead"), qlabBox); + qlabForm->addRow(tr("Passcode:"), m_qlabPasscode); + qlabForm->addRow(m_qlabSuppressBack); + layout->addWidget(qlabBox); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttons, &QDialogButtonBox::accepted, this, &SettingsDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &SettingsDialog::reject); + layout->addWidget(buttons); +} + +void SettingsDialog::loadValues() { + if (Show* show = m_app->show()) { + m_dimDcaFaders->setChecked(show->dimDcaFaders()); + m_selectOnSpill->setChecked(show->selectOnSpill()); + m_muteDcaUnassign->setChecked(show->muteDcaUnassign()); + m_suppressBackupSwitch->setChecked(show->suppressBackupSwitch()); + m_designer->setText(show->designer()); + } + + if (ScribbleController* scribble = m_app->scribbleController()) { + m_highlightEnabled->setChecked(scribble->isHighlightEnabled()); + m_highlightColor->setValue(scribble->highlightColor()); + } + + if (ChannelMonitor* monitor = m_app->channelMonitor()) { + m_silenceThreshold->setValue(monitor->silenceThreshold()); + m_clipThreshold->setValue(monitor->clipThreshold()); + m_silenceTimeoutMs->setValue(monitor->silenceTimeoutMs()); + m_clipHoldMs->setValue(monitor->clipHoldMs()); + } + + if (QLabClient* qlab = m_app->qLabClient()) { + m_qlabPasscode->setText(qlab->passcode()); + m_qlabSuppressBack->setChecked(qlab->suppressBack()); + } +} + +void SettingsDialog::applyValues() { + if (Show* show = m_app->show()) { + show->setDimDcaFaders(m_dimDcaFaders->isChecked()); + show->setSelectOnSpill(m_selectOnSpill->isChecked()); + show->setMuteDcaUnassign(m_muteDcaUnassign->isChecked()); + show->setSuppressBackupSwitch(m_suppressBackupSwitch->isChecked()); + show->setDesigner(m_designer->text()); + } + + // push the DCA-apply toggles to the live engine + if (PlaybackEngine* engine = m_app->playbackEngine()) { + engine->setDimDcaFaders(m_dimDcaFaders->isChecked()); + engine->setMuteDcaUnassign(m_muteDcaUnassign->isChecked()); + } + + // scribble highlight: drive the live controller and persist via QSettings + if (ScribbleController* scribble = m_app->scribbleController()) { + scribble->setHighlightColor(m_highlightColor->value()); + scribble->setHighlightEnabled(m_highlightEnabled->isChecked()); + } + { + QSettings settings; + settings.beginGroup("Scribble"); + settings.setValue("highlightEnabled", m_highlightEnabled->isChecked()); + settings.setValue("highlightColor", m_highlightColor->value()); + settings.endGroup(); + } + + // channel monitor thresholds: drive the live monitor and persist via QSettings + if (ChannelMonitor* monitor = m_app->channelMonitor()) { + monitor->setSilenceThreshold(m_silenceThreshold->value()); + monitor->setClipThreshold(m_clipThreshold->value()); + monitor->setSilenceTimeoutMs(m_silenceTimeoutMs->value()); + monitor->setClipHoldMs(m_clipHoldMs->value()); + } + { + QSettings settings; + settings.beginGroup("Monitor"); + settings.setValue("silenceThreshold", m_silenceThreshold->value()); + settings.setValue("clipThreshold", m_clipThreshold->value()); + settings.setValue("silenceTimeoutMs", m_silenceTimeoutMs->value()); + settings.setValue("clipHoldMs", m_clipHoldMs->value()); + settings.endGroup(); + } + + if (QLabClient* qlab = m_app->qLabClient()) { + qlab->setPasscode(m_qlabPasscode->text()); + qlab->setSuppressBack(m_qlabSuppressBack->isChecked()); + qlab->saveToSettings(); + } +} + +void SettingsDialog::accept() { + applyValues(); + QDialog::accept(); +} + +} // namespace OpenMix diff --git a/src/ui/SettingsDialog.h b/src/ui/SettingsDialog.h new file mode 100644 index 0000000..c2a05fd --- /dev/null +++ b/src/ui/SettingsDialog.h @@ -0,0 +1,61 @@ +#pragma once + +#include + +class QCheckBox; +class QDoubleSpinBox; +class QLineEdit; +class QSpinBox; + +namespace OpenMix { + +class Application; + +// Application settings dialog for the console-workflow refinements: +// - console-behavior toggles (Show + PlaybackEngine) +// - show metadata (designer) +// - active-channel scribble highlight (ScribbleController) +// - channel monitor silence/clip thresholds (ChannelMonitor) +// - QLab passcode / suppress-back (QLabClient) +// Drives the live objects and persists (project fields on the Show, the rest via +// QSettings / each service's saveToSettings). +class SettingsDialog : public QDialog { + Q_OBJECT + + public: + explicit SettingsDialog(Application* app, QWidget* parent = nullptr); + + void accept() override; + + private: + void setupUi(); + void loadValues(); + void applyValues(); + + Application* m_app; + + // console behavior + QCheckBox* m_dimDcaFaders = nullptr; + QCheckBox* m_selectOnSpill = nullptr; + QCheckBox* m_muteDcaUnassign = nullptr; + QCheckBox* m_suppressBackupSwitch = nullptr; + + // show metadata + QLineEdit* m_designer = nullptr; + + // scribble highlight + QCheckBox* m_highlightEnabled = nullptr; + QSpinBox* m_highlightColor = nullptr; + + // channel monitor thresholds + QDoubleSpinBox* m_silenceThreshold = nullptr; + QDoubleSpinBox* m_clipThreshold = nullptr; + QSpinBox* m_silenceTimeoutMs = nullptr; + QSpinBox* m_clipHoldMs = nullptr; + + // QLab remote + QLineEdit* m_qlabPasscode = nullptr; + QCheckBox* m_qlabSuppressBack = nullptr; +}; + +} // namespace OpenMix diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c9d73bc..20385f0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -116,6 +116,14 @@ target_link_libraries(test_msc PRIVATE Qt6::Core Qt6::Test) target_include_directories(test_msc PRIVATE ${CMAKE_SOURCE_DIR}/src) add_test(NAME MscTest COMMAND test_msc) +add_executable(test_midi_mute + test_midi_mute.cpp + ${CMAKE_SOURCE_DIR}/src/midi/MidiControlMapping.cpp +) +target_link_libraries(test_midi_mute PRIVATE Qt6::Core Qt6::Test) +target_include_directories(test_midi_mute PRIVATE ${CMAKE_SOURCE_DIR}/src) +add_test(NAME MidiMuteTest COMMAND test_midi_mute) + add_executable(test_osc_remote test_osc_remote.cpp ${CMAKE_SOURCE_DIR}/src/app/OscRemoteServer.cpp diff --git a/tests/test_actor_profiles.cpp b/tests/test_actor_profiles.cpp index 943c1df..f2a9dc9 100644 --- a/tests/test_actor_profiles.cpp +++ b/tests/test_actor_profiles.cpp @@ -174,7 +174,7 @@ class TestActorProfiles : public QObject { QVERIFY(spy.count() >= 1); const QJsonObject json = show.toJson(); - QCOMPARE(json["version"].toString(), QString("1.3")); + QCOMPARE(json["version"].toString(), QString("1.4")); Show loaded; loaded.fromJson(json); diff --git a/tests/test_ensembles.cpp b/tests/test_ensembles.cpp index 3481ed4..96ca658 100644 --- a/tests/test_ensembles.cpp +++ b/tests/test_ensembles.cpp @@ -113,7 +113,7 @@ class TestEnsembles : public QObject { QVERIFY(spy.count() >= 1); const QJsonObject json = show.toJson(); - QCOMPARE(json["version"].toString(), QString("1.3")); + QCOMPARE(json["version"].toString(), QString("1.4")); Show loaded; loaded.fromJson(json); diff --git a/tests/test_midi_mute.cpp b/tests/test_midi_mute.cpp new file mode 100644 index 0000000..d1d5130 --- /dev/null +++ b/tests/test_midi_mute.cpp @@ -0,0 +1,50 @@ +#include "midi/MidiControlMapping.h" +#include + +using namespace OpenMix; + +class TestMidiMute : public QObject { + Q_OBJECT + + private slots: + void muteAssignment_roundtrip() { + MidiMuteAssignment mute; + mute.trigger.type = MidiMessageType::NoteOn; + mute.trigger.channel = 2; + mute.trigger.noteOrCC = 48; + mute.trigger.minValue = 1; + mute.channel = 7; + + MidiMuteAssignment loaded = MidiMuteAssignment::fromJson(mute.toJson()); + QCOMPARE(loaded.channel, 7); + QCOMPARE(loaded.trigger.channel, 2); + QCOMPARE(loaded.trigger.noteOrCC, 48); + QVERIFY(loaded.trigger.type == MidiMessageType::NoteOn); + QVERIFY(loaded == mute); + } + + void muteAssignment_triggerMatches() { + MidiMuteAssignment mute; + mute.trigger.type = MidiMessageType::ControlChange; + mute.trigger.channel = 0; + mute.trigger.noteOrCC = 10; + mute.trigger.minValue = 64; + mute.channel = 3; + + // matches only a CC on channel 0, controller 10, value >= 64 + QVERIFY(mute.trigger.matches(MidiMessageType::ControlChange, 0, 10, 100)); + QVERIFY(!mute.trigger.matches(MidiMessageType::ControlChange, 0, 10, 20)); + QVERIFY(!mute.trigger.matches(MidiMessageType::NoteOn, 0, 10, 100)); + } + + void muteAssignment_inequality() { + MidiMuteAssignment a; + a.channel = 1; + MidiMuteAssignment b; + b.channel = 2; + QVERIFY(a != b); + } +}; + +QTEST_MAIN(TestMidiMute) +#include "test_midi_mute.moc" diff --git a/tests/test_qlab_client.cpp b/tests/test_qlab_client.cpp index d705a67..3c27d79 100644 --- a/tests/test_qlab_client.cpp +++ b/tests/test_qlab_client.cpp @@ -69,6 +69,56 @@ class TestQLabClient : public QObject { lo_server_thread_stop(srv); lo_server_thread_free(srv); } + + void passcodeSendsConnectBeforeCommand() { + Recorder rec; + lo_server_thread srv = lo_server_thread_new(nullptr, nullptr); + QVERIFY(srv); + lo_server_thread_add_method(srv, nullptr, nullptr, record, &rec); + lo_server_thread_start(srv); + + QLabClient client; + client.setTarget("127.0.0.1", lo_server_thread_get_port(srv)); + client.setPasscode("secret"); + client.setEnabled(true); + client.triggerCue("7"); + + QTRY_VERIFY(rec.paths.contains("/connect")); + QTRY_VERIFY(rec.paths.contains("/cue/7/start")); + + // /connect is sent only once per target + rec.paths.clear(); + client.triggerCue("8"); + QTRY_VERIFY(rec.paths.contains("/cue/8/start")); + QVERIFY(!rec.paths.contains("/connect")); + + lo_server_thread_stop(srv); + lo_server_thread_free(srv); + } + + void suppressBackGatesBack() { + Recorder rec; + lo_server_thread srv = lo_server_thread_new(nullptr, nullptr); + QVERIFY(srv); + lo_server_thread_add_method(srv, nullptr, nullptr, record, &rec); + lo_server_thread_start(srv); + + QLabClient client; + client.setTarget("127.0.0.1", lo_server_thread_get_port(srv)); + client.setEnabled(true); + + client.setSuppressBack(true); + client.back(); + QTest::qWait(50); + QVERIFY(!rec.paths.contains("/playhead/previous")); + + client.setSuppressBack(false); + client.back(); + QTRY_VERIFY(rec.paths.contains("/playhead/previous")); + + lo_server_thread_stop(srv); + lo_server_thread_free(srv); + } }; QTEST_MAIN(TestQLabClient) diff --git a/tests/test_scribble.cpp b/tests/test_scribble.cpp index 36f33ce..3f8d7c7 100644 --- a/tests/test_scribble.cpp +++ b/tests/test_scribble.cpp @@ -3,6 +3,7 @@ #include "core/ChannelMonitor.h" #include "core/Cue.h" #include "core/CueList.h" +#include "core/DCAMapping.h" #include "core/ScribbleController.h" #include "protocol/MixerProtocol.h" #include @@ -40,6 +41,15 @@ class RecordingMixer : public MixerProtocol { [[nodiscard]] bool hasName(int ch, const QString& name) const { return nameCalls.contains({ch, name}); } + + // most recent color pushed to a channel, or -1 if none + [[nodiscard]] int lastColor(int ch) const { + int color = -1; + for (const auto& call : colorCalls) + if (call.first == ch) + color = call.second; + return color; + } }; class TestScribble : public QObject { @@ -174,6 +184,113 @@ class TestScribble : public QObject { QVERIFY(mixer.nameCalls.isEmpty()); QVERIFY(mixer.colorCalls.isEmpty()); } + + // --- active-channel highlight --- + + void highlight_colorsTouchedChannels() { + CueList cues; + Cue cue(1.0, "A"); + cue.setChannelLevel(3, 0.5); + cue.setChannelProfile(7, "slot"); + cues.addCue(cue); + + RecordingMixer mixer; + ScribbleController ctl; + ctl.setCueList(&cues); + ctl.setMixer(&mixer); + ctl.setHighlightColor(2); + ctl.setHighlightEnabled(true); + mixer.colorCalls.clear(); + + ctl.onCurrentCueChanged(0); + QCOMPARE(mixer.lastColor(3), 2); + QCOMPARE(mixer.lastColor(7), 2); + } + + void highlight_restoresUntouchedOnCueChange() { + CueList cues; + Cue a(1.0, "A"); + a.setChannelLevel(3, 0.5); + cues.addCue(a); + Cue b(2.0, "B"); + b.setChannelLevel(5, 0.5); + cues.addCue(b); + + RecordingMixer mixer; + ScribbleController ctl; + ctl.setCueList(&cues); + ctl.setMixer(&mixer); + ctl.setHighlightColor(2); + ctl.setHighlightEnabled(true); + + ctl.onCurrentCueChanged(0); + QCOMPARE(mixer.lastColor(3), 2); + + ctl.onCurrentCueChanged(1); + QCOMPARE(mixer.lastColor(5), 2); // now highlighted + QCOMPARE(mixer.lastColor(3), ctl.stateColor(ChannelState::Normal)); // restored + } + + void highlight_includesDcaAssignedChannels() { + CueList cues; + cues.addCue(Cue(1.0, "A")); // targets all DCAs + + DCAMapping dca; + dca.assignChannelToDCA(2, 1); + dca.assignChannelToDCA(4, 1); + + RecordingMixer mixer; + ScribbleController ctl; + ctl.setCueList(&cues); + ctl.setDCAMapping(&dca); + ctl.setMixer(&mixer); + ctl.setHighlightColor(2); + ctl.setHighlightEnabled(true); + mixer.colorCalls.clear(); + + ctl.onCurrentCueChanged(0); + QCOMPARE(mixer.lastColor(2), 2); + QCOMPARE(mixer.lastColor(4), 2); + } + + void highlight_disabledDoesNotColor() { + CueList cues; + Cue cue(1.0, "A"); + cue.setChannelLevel(3, 0.5); + cues.addCue(cue); + + RecordingMixer mixer; + ScribbleController ctl; + ctl.setCueList(&cues); + ctl.setMixer(&mixer); + mixer.colorCalls.clear(); + + ctl.onCurrentCueChanged(0); // highlight off by default + QVERIFY(mixer.colorCalls.isEmpty()); + } + + void highlight_keepsColorUnderNormalState() { + CueList cues; + Cue cue(1.0, "A"); + cue.setChannelLevel(3, 0.5); + cues.addCue(cue); + + RecordingMixer mixer; + ScribbleController ctl; + ctl.setCueList(&cues); + ctl.setMixer(&mixer); + ctl.setHighlightColor(2); + ctl.setHighlightEnabled(true); + ctl.onCurrentCueChanged(0); + + // a Normal-state report on a highlighted channel keeps the highlight + ctl.onChannelStateChanged(3, static_cast(ChannelState::Normal)); + QCOMPARE(mixer.lastColor(3), 2); + + // a clip warning still wins + ctl.onChannelStateChanged(3, static_cast(ChannelState::Clipping)); + QCOMPARE(mixer.lastColor(3), ctl.stateColor(ChannelState::Clipping)); + } }; QTEST_MAIN(TestScribble) diff --git a/tests/test_show_control.cpp b/tests/test_show_control.cpp index 8e361b4..b0cf438 100644 --- a/tests/test_show_control.cpp +++ b/tests/test_show_control.cpp @@ -48,6 +48,10 @@ class RecordingMixer : public MixerProtocol { calls << QString("fader:ch=%1:level=%2").arg(channel).arg(level); } + void setChannelMute(int channel, bool muted) override { + calls << QString("mute:ch=%1:%2").arg(channel).arg(muted ? 1 : 0); + } + void refresh() override {} int latencyMs() const override { return 0; } @@ -118,6 +122,46 @@ class TestShowControl : public QObject { QCOMPARE(loaded.channelGangs().at(1), qMakePair(3, 4)); } + void show_consoleToggles_roundtrip() { + Show show; + show.setDimDcaFaders(true); + show.setSelectOnSpill(true); + show.setMuteDcaUnassign(true); + show.setSuppressBackupSwitch(true); + show.setDesigner("Jane Doe"); + + Show loaded; + loaded.fromJson(show.toJson()); + QVERIFY(loaded.dimDcaFaders()); + QVERIFY(loaded.selectOnSpill()); + QVERIFY(loaded.muteDcaUnassign()); + QVERIFY(loaded.suppressBackupSwitch()); + QCOMPARE(loaded.designer(), QString("Jane Doe")); + + // defaults are all off + Show fresh; + Show freshLoaded; + freshLoaded.fromJson(fresh.toJson()); + QVERIFY(!freshLoaded.dimDcaFaders()); + QVERIFY(!freshLoaded.muteDcaUnassign()); + } + + void show_gangMeta_roundtrip() { + Show show; + show.setChannelGangs({qMakePair(1, 2), qMakePair(3, 4)}); + show.setGangName(0, "Vocals L/R"); + show.setGangColor(0, "#ff0000"); + show.setGangName(1, "FX"); + + Show loaded; + loaded.fromJson(show.toJson()); + QCOMPARE(loaded.gangName(0), QString("Vocals L/R")); + QCOMPARE(loaded.gangColor(0), QString("#ff0000")); + QCOMPARE(loaded.gangName(1), QString("FX")); + // gang metadata stays aligned with the gang list + QCOMPARE(loaded.channelGangs().size(), 2); + } + // --- on-fire playback --- void fire_sendsFxMutes() { @@ -193,6 +237,93 @@ class TestShowControl : public QObject { QVERIFY(!mixer->calls.contains("fader:ch=6:level=0.4")); } + // --- channel mute buttons --- + + void toggleChannelMute_flipsAndDispatches() { + RecordingMixer* mixer = makeConnectedMixer(this); + PlaybackEngine engine; + engine.setMixer(mixer); + + QSignalSpy spy(&engine, &PlaybackEngine::channelMuteChanged); + + engine.toggleChannelMute(5); + QVERIFY(engine.isChannelMuted(5)); + QVERIFY2(mixer->calls.contains("mute:ch=5:1"), qPrintable(mixer->calls.join(" | "))); + + engine.toggleChannelMute(5); + QVERIFY(!engine.isChannelMuted(5)); + QVERIFY(mixer->calls.contains("mute:ch=5:0")); + + QCOMPARE(spy.count(), 2); + QCOMPARE(spy.at(0).at(0).toInt(), 5); + QCOMPARE(spy.at(0).at(1).toBool(), true); + } + + // --- DCA console-behavior toggles on fire --- + + void fire_muteDcaUnassign_sendsUnassign() { + CueList cues; + Cue cue(1.0, "A"); + cue.setType(CueType::Snapshot); + DCAOverride ov; + ov.mute = true; + cue.setTargetedDCAs({2}); + cue.setDCAOverride(2, ov); + cues.addCue(cue); + + RecordingMixer* mixer = makeConnectedMixer(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setMixer(mixer); + engine.setMuteDcaUnassign(true); + engine.executeCue(0); + + QVERIFY2(mixer->calls.contains("send:/dca/2/mute=1"), qPrintable(mixer->calls.join(" | "))); + QVERIFY(mixer->calls.contains("send:/dca/2/assign=0")); + } + + void fire_dimDcaFaders_sendsFaderDim() { + CueList cues; + Cue cue(1.0, "A"); + cue.setType(CueType::Snapshot); + DCAOverride ov; + ov.mute = true; + cue.setTargetedDCAs({2}); + cue.setDCAOverride(2, ov); + cues.addCue(cue); + + RecordingMixer* mixer = makeConnectedMixer(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setMixer(mixer); + engine.setDimDcaFaders(true); + engine.executeCue(0); + + QVERIFY2(mixer->calls.contains("send:/dca/2/fader=0"), qPrintable(mixer->calls.join(" | "))); + } + + void fire_dcaToggles_offByDefault() { + CueList cues; + Cue cue(1.0, "A"); + cue.setType(CueType::Snapshot); + DCAOverride ov; + ov.mute = true; + cue.setTargetedDCAs({2}); + cue.setDCAOverride(2, ov); + cues.addCue(cue); + + RecordingMixer* mixer = makeConnectedMixer(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setMixer(mixer); + engine.executeCue(0); + + // only the plain mute is sent; no unassign/dim side effects + QVERIFY(mixer->calls.contains("send:/dca/2/mute=1")); + QVERIFY(!mixer->calls.contains("send:/dca/2/assign=0")); + QVERIFY(!mixer->calls.contains("send:/dca/2/fader=0")); + } + // --- skip + check mode --- void advanceStandby_stepsOverSkippedCue() { From c4900603972bd0135dcbb59e96bff2c45e2a8d06 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 00:37:30 -0400 Subject: [PATCH 12/92] feat: UI panels for positions, cue zero, and timecode triggers --- CMakeLists.txt | 6 + src/ui/CueEditor.cpp | 49 +++++++- src/ui/CueEditor.h | 1 + src/ui/CueZeroDialog.cpp | 183 +++++++++++++++++++++++++++++ src/ui/CueZeroDialog.h | 39 +++++++ src/ui/MainWindow.cpp | 77 +++++++++++++ src/ui/MainWindow.h | 12 ++ src/ui/PositionPanel.cpp | 243 +++++++++++++++++++++++++++++++++++++++ src/ui/PositionPanel.h | 58 ++++++++++ src/ui/TimecodePanel.cpp | 207 +++++++++++++++++++++++++++++++++ src/ui/TimecodePanel.h | 56 +++++++++ 11 files changed, 929 insertions(+), 2 deletions(-) create mode 100644 src/ui/CueZeroDialog.cpp create mode 100644 src/ui/CueZeroDialog.h create mode 100644 src/ui/PositionPanel.cpp create mode 100644 src/ui/PositionPanel.h create mode 100644 src/ui/TimecodePanel.cpp create mode 100644 src/ui/TimecodePanel.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 5abd244..c43ea39 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -122,6 +122,9 @@ set(SOURCES src/ui/EnsemblePanel.cpp src/ui/RemoteControlDialog.cpp src/ui/SettingsDialog.cpp + src/ui/PositionPanel.cpp + src/ui/TimecodePanel.cpp + src/ui/CueZeroDialog.cpp src/ui/PopOutWindow.cpp src/ui/BubbleButton.cpp src/ui/BubbleBar.cpp @@ -220,6 +223,9 @@ set(HEADERS src/ui/EnsemblePanel.h src/ui/RemoteControlDialog.h src/ui/SettingsDialog.h + src/ui/PositionPanel.h + src/ui/TimecodePanel.h + src/ui/CueZeroDialog.h src/ui/PopOutWindow.h src/ui/BubbleButton.h src/ui/BubbleBar.h diff --git a/src/ui/CueEditor.cpp b/src/ui/CueEditor.cpp index 9752a2f..aaaff93 100644 --- a/src/ui/CueEditor.cpp +++ b/src/ui/CueEditor.cpp @@ -6,6 +6,7 @@ #include "core/CueList.h" #include "core/FadeCurve.h" #include "core/PlaybackEngine.h" +#include "core/Position.h" #include "core/Show.h" #include "theme/Theme.h" @@ -321,9 +322,9 @@ void CueEditor::createChannelProfilesSection() { hint->setStyleSheet(QString("color: %1;").arg(Theme::Colors::TextSecondary)); layout->addWidget(hint); - m_channelTable = new QTableWidget(0, 5, m_channelProfilesGroup); + m_channelTable = new QTableWidget(0, 6, m_channelProfilesGroup); m_channelTable->setHorizontalHeaderLabels( - {tr("Ch"), tr("Actor"), tr("Profile"), tr("Set"), tr("Level")}); + {tr("Ch"), tr("Actor"), tr("Profile"), tr("Set"), tr("Level"), tr("Position")}); m_channelTable->verticalHeader()->setVisible(false); m_channelTable->setSelectionMode(QAbstractItemView::NoSelection); m_channelTable->setEditTriggers(QAbstractItemView::NoEditTriggers); @@ -761,6 +762,17 @@ void CueEditor::rebuildChannelTable() { connect(levelSpin, QOverload::of(&QSpinBox::valueChanged), this, [this, ch]() { onChannelLevelChanged(ch); }); m_channelTable->setCellWidget(row, 4, levelSpin); + + auto* positionCombo = new QComboBox(m_channelTable); + positionCombo->addItem(tr("(none)"), QString()); + if (m_app && m_app->show()) { + for (const Position& p : m_app->show()->positionLibrary()->positions()) + positionCombo->addItem(p.name().isEmpty() ? tr("(unnamed)") : p.name(), p.id()); + } + positionCombo->setProperty("channel", ch); + connect(positionCombo, QOverload::of(&QComboBox::currentIndexChanged), this, + [this, ch]() { onChannelPositionChanged(ch); }); + m_channelTable->setCellWidget(row, 5, positionCombo); } } @@ -788,6 +800,11 @@ void CueEditor::populateChannelTable() { spin->setEnabled(hasLevel); spin->setValue(hasLevel ? std::clamp(static_cast(levels.value(ch) * 100.0 + 0.5), 0, 100) : 75); + + if (auto* posCombo = qobject_cast(m_channelTable->cellWidget(row, 5))) { + const int posIdx = posCombo->findData(cue->channelPosition(ch)); + posCombo->setCurrentIndex(posIdx < 0 ? 0 : posIdx); + } } } @@ -852,6 +869,34 @@ void CueEditor::onChannelProfileChanged(int channel) { emit cueModified(); } +void CueEditor::onChannelPositionChanged(int channel) { + if (m_updatingUi) + return; + Cue* cue = currentCue(); + if (!cue || !m_channelTable) + return; + + QComboBox* combo = nullptr; + for (int row = 0; row < m_channelTable->rowCount(); ++row) { + auto* c = qobject_cast(m_channelTable->cellWidget(row, 5)); + if (c && c->property("channel").toInt() == channel) { + combo = c; + break; + } + } + if (!combo) + return; + + const QString positionId = combo->currentData().toString(); + if (positionId.isEmpty()) + cue->clearChannelPosition(channel); + else + cue->setChannelPosition(channel, positionId); + + m_app->show()->cueList()->updateCue(m_currentIndex, *cue); + emit cueModified(); +} + void CueEditor::onChannelLevelToggled(int channel, bool on) { if (m_updatingUi) return; diff --git a/src/ui/CueEditor.h b/src/ui/CueEditor.h index cb11af4..cd37fa4 100644 --- a/src/ui/CueEditor.h +++ b/src/ui/CueEditor.h @@ -52,6 +52,7 @@ class CueEditor : public QWidget { void onFadeCurveChanged(int index); void onQLabCueChanged(const QString& text); void onChannelProfileChanged(int channel); + void onChannelPositionChanged(int channel); void onChannelLevelToggled(int channel, bool on); void onChannelLevelChanged(int channel); void onActorLibraryChanged(); diff --git a/src/ui/CueZeroDialog.cpp b/src/ui/CueZeroDialog.cpp new file mode 100644 index 0000000..bdb3838 --- /dev/null +++ b/src/ui/CueZeroDialog.cpp @@ -0,0 +1,183 @@ +#include "CueZeroDialog.h" +#include "app/Application.h" +#include "core/CueZero.h" +#include "core/Show.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace OpenMix { + +CueZeroDialog::CueZeroDialog(Application* app, QWidget* parent) : QDialog(parent), m_app(app) { + setWindowTitle(tr("Cue Zero")); + setModal(true); + resize(480, 520); + setupUi(); + load(); +} + +void CueZeroDialog::setupUi() { + QVBoxLayout* mainLayout = new QVBoxLayout(this); + + QLabel* intro = new QLabel( + tr("Base/reset state recalled before the first cue. These values also " + "serve as the panic safe-values."), + this); + intro->setWordWrap(true); + mainLayout->addWidget(intro); + + // base scene + QHBoxLayout* sceneLayout = new QHBoxLayout(); + sceneLayout->addWidget(new QLabel(tr("Base scene:"), this)); + m_baseSceneSpin = new QSpinBox(this); + m_baseSceneSpin->setRange(-1, 9999); + m_baseSceneSpin->setSpecialValueText(tr("None")); + m_baseSceneSpin->setToolTip(tr("Scene recalled first; None to skip")); + sceneLayout->addWidget(m_baseSceneSpin); + sceneLayout->addStretch(); + mainLayout->addLayout(sceneLayout); + + // levels editor + QGroupBox* levelsGroup = new QGroupBox(tr("Levels (path → value)"), this); + QVBoxLayout* levelsLayout = new QVBoxLayout(levelsGroup); + m_levelsTable = new QTableWidget(0, 2, levelsGroup); + m_levelsTable->setHorizontalHeaderLabels({tr("Path"), tr("Value")}); + m_levelsTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch); + m_levelsTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::ResizeToContents); + m_levelsTable->verticalHeader()->setVisible(false); + levelsLayout->addWidget(m_levelsTable); + QHBoxLayout* levelsButtons = new QHBoxLayout(); + QPushButton* addLevel = new QPushButton(tr("Add"), levelsGroup); + connect(addLevel, &QPushButton::clicked, this, &CueZeroDialog::addLevelRow); + QPushButton* removeLevel = new QPushButton(tr("Remove"), levelsGroup); + connect(removeLevel, &QPushButton::clicked, this, &CueZeroDialog::removeLevelRow); + levelsButtons->addStretch(); + levelsButtons->addWidget(addLevel); + levelsButtons->addWidget(removeLevel); + levelsLayout->addLayout(levelsButtons); + mainLayout->addWidget(levelsGroup); + + // labels editor + QGroupBox* labelsGroup = new QGroupBox(tr("Labels (path → name)"), this); + QVBoxLayout* labelsLayout = new QVBoxLayout(labelsGroup); + m_labelsTable = new QTableWidget(0, 2, labelsGroup); + m_labelsTable->setHorizontalHeaderLabels({tr("Path"), tr("Name")}); + m_labelsTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch); + m_labelsTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::ResizeToContents); + m_labelsTable->verticalHeader()->setVisible(false); + labelsLayout->addWidget(m_labelsTable); + QHBoxLayout* labelsButtons = new QHBoxLayout(); + QPushButton* addLabel = new QPushButton(tr("Add"), labelsGroup); + connect(addLabel, &QPushButton::clicked, this, &CueZeroDialog::addLabelRow); + QPushButton* removeLabel = new QPushButton(tr("Remove"), labelsGroup); + connect(removeLabel, &QPushButton::clicked, this, &CueZeroDialog::removeLabelRow); + labelsButtons->addStretch(); + labelsButtons->addWidget(addLabel); + labelsButtons->addWidget(removeLabel); + labelsLayout->addLayout(labelsButtons); + mainLayout->addWidget(labelsGroup); + + QDialogButtonBox* buttons = + new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttons, &QDialogButtonBox::accepted, this, &CueZeroDialog::applyChanges); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + mainLayout->addWidget(buttons); +} + +void CueZeroDialog::load() { + if (!m_app || !m_app->show()) + return; + const CueZero* cueZero = m_app->show()->cueZero(); + + m_baseSceneSpin->setValue(cueZero->baseScene()); + + const QJsonObject levels = cueZero->levels(); + m_levelsTable->setRowCount(0); + for (auto it = levels.begin(); it != levels.end(); ++it) { + const int row = m_levelsTable->rowCount(); + m_levelsTable->insertRow(row); + m_levelsTable->setItem(row, 0, new QTableWidgetItem(it.key())); + m_levelsTable->setItem( + row, 1, new QTableWidgetItem(QString::number(it.value().toDouble()))); + } + + const QJsonObject labels = cueZero->labels(); + m_labelsTable->setRowCount(0); + for (auto it = labels.begin(); it != labels.end(); ++it) { + const int row = m_labelsTable->rowCount(); + m_labelsTable->insertRow(row); + m_labelsTable->setItem(row, 0, new QTableWidgetItem(it.key())); + m_labelsTable->setItem(row, 1, new QTableWidgetItem(it.value().toString())); + } +} + +void CueZeroDialog::addLevelRow() { + const int row = m_levelsTable->rowCount(); + m_levelsTable->insertRow(row); + m_levelsTable->setItem(row, 0, new QTableWidgetItem()); + m_levelsTable->setItem(row, 1, new QTableWidgetItem("0")); + m_levelsTable->editItem(m_levelsTable->item(row, 0)); +} + +void CueZeroDialog::removeLevelRow() { + const int row = m_levelsTable->currentRow(); + if (row >= 0) + m_levelsTable->removeRow(row); +} + +void CueZeroDialog::addLabelRow() { + const int row = m_labelsTable->rowCount(); + m_labelsTable->insertRow(row); + m_labelsTable->setItem(row, 0, new QTableWidgetItem()); + m_labelsTable->setItem(row, 1, new QTableWidgetItem()); + m_labelsTable->editItem(m_labelsTable->item(row, 0)); +} + +void CueZeroDialog::removeLabelRow() { + const int row = m_labelsTable->currentRow(); + if (row >= 0) + m_labelsTable->removeRow(row); +} + +void CueZeroDialog::applyChanges() { + if (!m_app || !m_app->show()) { + accept(); + return; + } + CueZero* cueZero = m_app->show()->cueZero(); + + cueZero->setBaseScene(m_baseSceneSpin->value()); + + QJsonObject levels; + for (int row = 0; row < m_levelsTable->rowCount(); ++row) { + QTableWidgetItem* pathItem = m_levelsTable->item(row, 0); + QTableWidgetItem* valueItem = m_levelsTable->item(row, 1); + if (!pathItem || pathItem->text().trimmed().isEmpty()) + continue; + const double value = valueItem ? valueItem->text().toDouble() : 0.0; + levels.insert(pathItem->text().trimmed(), value); + } + cueZero->setLevels(levels); + + QJsonObject labels; + for (int row = 0; row < m_labelsTable->rowCount(); ++row) { + QTableWidgetItem* pathItem = m_labelsTable->item(row, 0); + QTableWidgetItem* nameItem = m_labelsTable->item(row, 1); + if (!pathItem || pathItem->text().trimmed().isEmpty()) + continue; + labels.insert(pathItem->text().trimmed(), nameItem ? nameItem->text() : QString()); + } + cueZero->setLabels(labels); + + accept(); +} + +} // namespace OpenMix diff --git a/src/ui/CueZeroDialog.h b/src/ui/CueZeroDialog.h new file mode 100644 index 0000000..343926a --- /dev/null +++ b/src/ui/CueZeroDialog.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +class QSpinBox; +class QTableWidget; + +namespace OpenMix { + +class Application; + +// Edit the show's Cue Zero base/reset state: an optional base scene plus level +// (path -> value) and label (path -> name) parameters. Recalling Cue Zero returns +// the console to this known starting point; its values double as panic safe-values. +class CueZeroDialog : public QDialog { + Q_OBJECT + + public: + explicit CueZeroDialog(Application* app, QWidget* parent = nullptr); + + private slots: + void addLevelRow(); + void removeLevelRow(); + void addLabelRow(); + void removeLabelRow(); + void applyChanges(); + + private: + void setupUi(); + void load(); + + Application* m_app; + + QSpinBox* m_baseSceneSpin; + QTableWidget* m_levelsTable; + QTableWidget* m_labelsTable; +}; + +} // namespace OpenMix diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 3dfe715..2c69bc8 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -6,8 +6,11 @@ #include "CueEditor.h" #include "CueListView.h" #include "CueTableModel.h" +#include "CueZeroDialog.h" #include "DCAMappingPanel.h" #include "EnsemblePanel.h" +#include "PositionPanel.h" +#include "TimecodePanel.h" #include "LogViewerDialog.h" #include "MixerFeedbackPanel.h" #include "PopOutWindow.h" @@ -206,6 +209,24 @@ void MainWindow::createActions() { m_showEnsembleAction->setToolTip(tr("Show/hide ensembles panel (F10)")); connect(m_showEnsembleAction, &QAction::triggered, this, &MainWindow::toggleEnsemblePanel); + m_showPositionAction = new QAction(Icons::sliders(), tr("&Positions"), this); + m_showPositionAction->setCheckable(true); + m_showPositionAction->setChecked(false); + m_showPositionAction->setShortcut(Qt::Key_F11); + m_showPositionAction->setToolTip(tr("Show/hide positions panel (F11)")); + connect(m_showPositionAction, &QAction::triggered, this, &MainWindow::togglePositionPanel); + + m_showTimecodeAction = new QAction(Icons::sliders(), tr("&Timecode Triggers"), this); + m_showTimecodeAction->setCheckable(true); + m_showTimecodeAction->setChecked(false); + m_showTimecodeAction->setShortcut(Qt::Key_F12); + m_showTimecodeAction->setToolTip(tr("Show/hide timecode triggers panel (F12)")); + connect(m_showTimecodeAction, &QAction::triggered, this, &MainWindow::toggleTimecodePanel); + + m_cueZeroAction = new QAction(tr("Cue &Zero..."), this); + m_cueZeroAction->setToolTip(tr("Edit the base/reset state recalled before the first cue")); + connect(m_cueZeroAction, &QAction::triggered, this, &MainWindow::showCueZeroDialog); + m_showLogViewerAction = new QAction(tr("Application &Log..."), this); m_showLogViewerAction->setShortcut(Qt::Key_F8); m_showLogViewerAction->setToolTip(tr("Show application log (F8)")); @@ -277,6 +298,9 @@ void MainWindow::registerShortcuts() { sm->registerAction("view.connection", m_showConnectionAction, QKeySequence(Qt::Key_F7)); sm->registerAction("view.actorSetup", m_showActorSetupAction, QKeySequence(Qt::Key_F9)); sm->registerAction("view.ensembles", m_showEnsembleAction, QKeySequence(Qt::Key_F10)); + sm->registerAction("view.positions", m_showPositionAction, QKeySequence(Qt::Key_F11)); + sm->registerAction("view.timecode", m_showTimecodeAction, QKeySequence(Qt::Key_F12)); + sm->registerAction("view.cueZero", m_cueZeroAction, QKeySequence()); sm->registerAction("view.logViewer", m_showLogViewerAction, QKeySequence(Qt::Key_F8)); // settings actions @@ -326,6 +350,10 @@ void MainWindow::createMenus() { m_viewMenu->addAction(m_showConnectionAction); m_viewMenu->addAction(m_showActorSetupAction); m_viewMenu->addAction(m_showEnsembleAction); + m_viewMenu->addAction(m_showPositionAction); + m_viewMenu->addAction(m_showTimecodeAction); + m_viewMenu->addSeparator(); + m_viewMenu->addAction(m_cueZeroAction); m_viewMenu->addSeparator(); m_viewMenu->addAction(m_showLogViewerAction); @@ -423,6 +451,26 @@ void MainWindow::createPopOutWindows() { m_showEnsembleAction->setChecked(visible); m_bubbleBar->setButtonActive("ensembles", visible); }); + + m_positionPanel = new PositionPanel(m_app, nullptr); + m_positionPopOut = new PopOutWindow("positions", tr("Positions"), this); + m_positionPopOut->setContentWidget(m_positionPanel); + m_positionPopOut->setMinimumContentSize(420, 480); + + connect(m_positionPopOut, &PopOutWindow::visibilityChanged, [this](bool visible) { + m_showPositionAction->setChecked(visible); + m_bubbleBar->setButtonActive("positions", visible); + }); + + m_timecodePanel = new TimecodePanel(m_app, nullptr); + m_timecodePopOut = new PopOutWindow("timecode", tr("Timecode Triggers"), this); + m_timecodePopOut->setContentWidget(m_timecodePanel); + m_timecodePopOut->setMinimumContentSize(480, 420); + + connect(m_timecodePopOut, &PopOutWindow::visibilityChanged, [this](bool visible) { + m_showTimecodeAction->setChecked(visible); + m_bubbleBar->setButtonActive("timecode", visible); + }); } void MainWindow::createBubbleBar() { @@ -433,6 +481,8 @@ void MainWindow::createBubbleBar() { m_bubbleBar->addButton("connection", Icons::network(), tr("Connection (F7)")); m_bubbleBar->addButton("actors", Icons::actorSetup(), tr("Actor Setup (F9)")); m_bubbleBar->addButton("ensembles", Icons::actor(), tr("Ensembles (F10)")); + m_bubbleBar->addButton("positions", Icons::sliders(), tr("Positions (F11)")); + m_bubbleBar->addButton("timecode", Icons::sliders(), tr("Timecode Triggers (F12)")); connect(m_bubbleBar, &BubbleBar::buttonClicked, this, &MainWindow::onBubbleButtonClicked); @@ -451,6 +501,10 @@ void MainWindow::onBubbleButtonClicked(const QString& id, [[maybe_unused]] bool toggleActorSetupPanel(); } else if (id == "ensembles") { toggleEnsemblePanel(); + } else if (id == "positions") { + togglePositionPanel(); + } else if (id == "timecode") { + toggleTimecodePanel(); } } @@ -740,6 +794,29 @@ void MainWindow::toggleEnsemblePanel() { } } +void MainWindow::togglePositionPanel() { + if (m_positionPopOut->isVisible()) { + m_positionPopOut->hide(); + } else { + m_positionPopOut->showAndRestore(); + m_positionPanel->refresh(); + } +} + +void MainWindow::toggleTimecodePanel() { + if (m_timecodePopOut->isVisible()) { + m_timecodePopOut->hide(); + } else { + m_timecodePopOut->showAndRestore(); + m_timecodePanel->refresh(); + } +} + +void MainWindow::showCueZeroDialog() { + CueZeroDialog dialog(m_app, this); + dialog.exec(); +} + void MainWindow::updateTitle() { QString title = m_app->show()->name(); if (m_app->show()->isModified()) { diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 11dc21a..f992db7 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -19,6 +19,8 @@ class MixerFeedbackPanel; class DCAMappingPanel; class ActorSetupPanel; class EnsemblePanel; +class PositionPanel; +class TimecodePanel; class PopOutWindow; class BubbleBar; class PlaybackGuard; @@ -60,6 +62,9 @@ class MainWindow : public QMainWindow { void toggleDCAMappingPanel(); void toggleActorSetupPanel(); void toggleEnsemblePanel(); + void togglePositionPanel(); + void toggleTimecodePanel(); + void showCueZeroDialog(); // update UI state void updateTitle(); @@ -115,6 +120,8 @@ class MainWindow : public QMainWindow { DCAMappingPanel* m_dcaMappingPanel; ActorSetupPanel* m_actorSetupPanel; EnsemblePanel* m_ensemblePanel; + PositionPanel* m_positionPanel; + TimecodePanel* m_timecodePanel; // pop-out windows PopOutWindow* m_connectionPopOut; @@ -122,6 +129,8 @@ class MainWindow : public QMainWindow { PopOutWindow* m_dcaMappingPopOut; PopOutWindow* m_actorSetupPopOut; PopOutWindow* m_ensemblePopOut; + PopOutWindow* m_positionPopOut; + PopOutWindow* m_timecodePopOut; // bubble bar BubbleBar* m_bubbleBar; @@ -169,6 +178,9 @@ class MainWindow : public QMainWindow { QAction* m_showDCAMappingAction; QAction* m_showActorSetupAction; QAction* m_showEnsembleAction; + QAction* m_showPositionAction; + QAction* m_showTimecodeAction; + QAction* m_cueZeroAction; QAction* m_showLogViewerAction; // settings actions diff --git a/src/ui/PositionPanel.cpp b/src/ui/PositionPanel.cpp new file mode 100644 index 0000000..efbaa60 --- /dev/null +++ b/src/ui/PositionPanel.cpp @@ -0,0 +1,243 @@ +#include "PositionPanel.h" +#include "app/Application.h" +#include "core/Position.h" +#include "core/Show.h" +#include "theme/Icons.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace OpenMix { + +namespace { +constexpr int PositionIdRole = Qt::UserRole + 1; + +QList parseBuses(const QString& text) { + QList buses; + const QStringList tokens = text.split(QRegularExpression("[,\\s]+"), Qt::SkipEmptyParts); + for (const QString& token : tokens) { + bool ok = false; + int b = token.toInt(&ok); + if (ok && b > 0) + buses.append(b); + } + std::sort(buses.begin(), buses.end()); + buses.erase(std::unique(buses.begin(), buses.end()), buses.end()); + return buses; +} + +QString formatBuses(const QList& buses) { + QStringList parts; + for (int b : buses) + parts.append(QString::number(b)); + return parts.join(", "); +} + +QString itemText(const Position& p) { + const QString name = p.name().isEmpty() ? QObject::tr("(unnamed)") : p.name(); + if (p.shortName().isEmpty()) + return name; + return QString("%1 [%2]").arg(name, p.shortName()); +} +} // namespace + +PositionPanel::PositionPanel(Application* app, QWidget* parent) : QWidget(parent), m_app(app) { + if (m_app && m_app->show()) + m_library = m_app->show()->positionLibrary(); + + setupUi(); + refresh(); +} + +void PositionPanel::setupUi() { + QVBoxLayout* mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(8, 8, 8, 8); + mainLayout->setSpacing(8); + + QHBoxLayout* toolbarLayout = new QHBoxLayout(); + m_addButton = new QPushButton(Icons::listAdd(), tr("Add Position"), this); + m_addButton->setToolTip(tr("Create a new position")); + connect(m_addButton, &QPushButton::clicked, this, &PositionPanel::addPosition); + toolbarLayout->addWidget(m_addButton); + + m_removeButton = new QPushButton(Icons::listRemove(), tr("Remove"), this); + m_removeButton->setToolTip(tr("Delete the selected position")); + connect(m_removeButton, &QPushButton::clicked, this, &PositionPanel::removePosition); + toolbarLayout->addWidget(m_removeButton); + + toolbarLayout->addStretch(); + mainLayout->addLayout(toolbarLayout); + + m_list = new QListWidget(this); + connect(m_list, &QListWidget::itemSelectionChanged, this, &PositionPanel::onSelectionChanged); + mainLayout->addWidget(m_list, 1); + + QGroupBox* editorGroup = new QGroupBox(tr("Position"), this); + QFormLayout* form = new QFormLayout(editorGroup); + + m_nameEdit = new QLineEdit(editorGroup); + m_nameEdit->setPlaceholderText(tr("Position name")); + connect(m_nameEdit, &QLineEdit::editingFinished, this, &PositionPanel::onFieldsEdited); + form->addRow(tr("Name:"), m_nameEdit); + + m_shortNameEdit = new QLineEdit(editorGroup); + m_shortNameEdit->setPlaceholderText(tr("Short label")); + connect(m_shortNameEdit, &QLineEdit::editingFinished, this, &PositionPanel::onFieldsEdited); + form->addRow(tr("Short name:"), m_shortNameEdit); + + m_delaySpin = new QDoubleSpinBox(editorGroup); + m_delaySpin->setRange(0.0, 2000.0); + m_delaySpin->setDecimals(1); + m_delaySpin->setSingleStep(1.0); + m_delaySpin->setSuffix(tr(" ms")); + m_delaySpin->setToolTip(tr("Image-shift / time-alignment delay")); + connect(m_delaySpin, &QDoubleSpinBox::editingFinished, this, &PositionPanel::onFieldsEdited); + form->addRow(tr("Delay:"), m_delaySpin); + + m_panSpin = new QDoubleSpinBox(editorGroup); + m_panSpin->setRange(-1.0, 1.0); + m_panSpin->setDecimals(2); + m_panSpin->setSingleStep(0.05); + m_panSpin->setToolTip(tr("Pan: -1 full left, 0 center, +1 full right")); + connect(m_panSpin, &QDoubleSpinBox::editingFinished, this, &PositionPanel::onFieldsEdited); + form->addRow(tr("Pan:"), m_panSpin); + + m_busesEdit = new QLineEdit(editorGroup); + m_busesEdit->setPlaceholderText(tr("e.g. 1, 3, 5")); + m_busesEdit->setToolTip(tr("Buses this position images to; empty = main only")); + connect(m_busesEdit, &QLineEdit::editingFinished, this, &PositionPanel::onFieldsEdited); + form->addRow(tr("Buses:"), m_busesEdit); + + mainLayout->addWidget(editorGroup); +} + +void PositionPanel::refresh() { + if (m_app && m_app->show()) + m_library = m_app->show()->positionLibrary(); + + populateList(); + updateEditor(); +} + +void PositionPanel::populateList() { + const QString keepId = currentPositionId(); + + m_updatingUi = true; + m_list->clear(); + if (m_library) { + for (const Position& p : m_library->positions()) { + QListWidgetItem* item = new QListWidgetItem(itemText(p), m_list); + item->setData(PositionIdRole, p.id()); + if (p.id() == keepId) + m_list->setCurrentItem(item); + } + } + m_updatingUi = false; +} + +QString PositionPanel::currentPositionId() const { + QListWidgetItem* item = m_list->currentItem(); + return item ? item->data(PositionIdRole).toString() : QString(); +} + +void PositionPanel::setEditorEnabled(bool enabled) { + m_nameEdit->setEnabled(enabled); + m_shortNameEdit->setEnabled(enabled); + m_delaySpin->setEnabled(enabled); + m_panSpin->setEnabled(enabled); + m_busesEdit->setEnabled(enabled); + m_removeButton->setEnabled(enabled); +} + +void PositionPanel::updateEditor() { + std::optional p = + m_library ? m_library->position(currentPositionId()) : std::nullopt; + + m_updatingUi = true; + if (p) { + m_nameEdit->setText(p->name()); + m_shortNameEdit->setText(p->shortName()); + m_delaySpin->setValue(p->delay()); + m_panSpin->setValue(p->pan()); + m_busesEdit->setText(formatBuses(p->buses())); + setEditorEnabled(true); + } else { + m_nameEdit->clear(); + m_shortNameEdit->clear(); + m_delaySpin->setValue(0.0); + m_panSpin->setValue(0.0); + m_busesEdit->clear(); + setEditorEnabled(false); + } + m_updatingUi = false; +} + +void PositionPanel::addPosition() { + if (!m_library) + return; + Position p(tr("New Position")); + m_library->addPosition(p); + + QListWidgetItem* item = new QListWidgetItem(itemText(p), m_list); + item->setData(PositionIdRole, p.id()); + m_list->setCurrentItem(item); + updateEditor(); + m_nameEdit->setFocus(); + m_nameEdit->selectAll(); +} + +void PositionPanel::removePosition() { + if (!m_library) + return; + const QString id = currentPositionId(); + if (id.isEmpty()) + return; + m_library->removePosition(id); + delete m_list->takeItem(m_list->currentRow()); + updateEditor(); +} + +void PositionPanel::onSelectionChanged() { + if (m_updatingUi) + return; + updateEditor(); +} + +void PositionPanel::onFieldsEdited() { + if (m_updatingUi) + return; + commitField(); +} + +void PositionPanel::commitField() { + if (!m_library) + return; + std::optional current = m_library->position(currentPositionId()); + if (!current) + return; + + Position p = *current; + p.setName(m_nameEdit->text()); + p.setShortName(m_shortNameEdit->text()); + p.setDelay(m_delaySpin->value()); + p.setPan(m_panSpin->value()); + p.setBuses(parseBuses(m_busesEdit->text())); + m_library->updatePosition(p); + + m_updatingUi = true; + m_busesEdit->setText(formatBuses(p.buses())); // reflect normalization + m_updatingUi = false; + if (QListWidgetItem* item = m_list->currentItem()) + item->setText(itemText(p)); +} + +} // namespace OpenMix diff --git a/src/ui/PositionPanel.h b/src/ui/PositionPanel.h new file mode 100644 index 0000000..ffa95d5 --- /dev/null +++ b/src/ui/PositionPanel.h @@ -0,0 +1,58 @@ +#pragma once + +#include + +class QDoubleSpinBox; +class QLabel; +class QLineEdit; +class QListWidget; +class QPushButton; + +namespace OpenMix { + +class Application; +class PositionLibrary; + +// Manage the show's named spatial positions: reusable pan/delay/bus presets a +// cue can assign to channels. Pop-out panel backed by the Show-owned +// PositionLibrary; edits write straight back to it. +class PositionPanel : public QWidget { + Q_OBJECT + + public: + explicit PositionPanel(Application* app, QWidget* parent = nullptr); + + public slots: + void refresh(); + + private slots: + void addPosition(); + void removePosition(); + void onSelectionChanged(); + void onFieldsEdited(); + + private: + void setupUi(); + void populateList(); + void updateEditor(); + void setEditorEnabled(bool enabled); + [[nodiscard]] QString currentPositionId() const; + void commitField(); + + Application* m_app; + PositionLibrary* m_library = nullptr; + + QListWidget* m_list; + QPushButton* m_addButton; + QPushButton* m_removeButton; + + QLineEdit* m_nameEdit; + QLineEdit* m_shortNameEdit; + QDoubleSpinBox* m_delaySpin; + QDoubleSpinBox* m_panSpin; + QLineEdit* m_busesEdit; + + bool m_updatingUi = false; +}; + +} // namespace OpenMix diff --git a/src/ui/TimecodePanel.cpp b/src/ui/TimecodePanel.cpp new file mode 100644 index 0000000..6c29430 --- /dev/null +++ b/src/ui/TimecodePanel.cpp @@ -0,0 +1,207 @@ +#include "TimecodePanel.h" +#include "app/Application.h" +#include "core/TimecodeTrigger.h" +#include "midi/MidiInputManager.h" +#include "theme/Icons.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace OpenMix { + +namespace { +constexpr int TriggerIdRole = Qt::UserRole + 1; + +QString formatTimecode(int h, int m, int s, int f) { + return QString("%1:%2:%3:%4") + .arg(h, 2, 10, QChar('0')) + .arg(m, 2, 10, QChar('0')) + .arg(s, 2, 10, QChar('0')) + .arg(f, 2, 10, QChar('0')); +} + +QString formatCue(double number) { + QString text = QString::number(number, 'f', 2); + while (text.endsWith('0')) + text.chop(1); + if (text.endsWith('.')) + text.chop(1); + return text; +} +} // namespace + +TimecodePanel::TimecodePanel(Application* app, QWidget* parent) : QWidget(parent), m_app(app) { + if (m_app) + m_triggers = m_app->timecodeTriggers(); + + setupUi(); + + if (m_triggers) + connect(m_triggers, &TimecodeTriggerList::triggerFired, this, + &TimecodePanel::onTriggerFired); + if (m_app && m_app->midiInputManager()) + connect(m_app->midiInputManager(), &MidiInputManager::timecodeChanged, this, + &TimecodePanel::onIncomingTimecode); + + refresh(); +} + +void TimecodePanel::setupUi() { + QVBoxLayout* mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(8, 8, 8, 8); + mainLayout->setSpacing(8); + + // live incoming timecode readout + m_liveLabel = new QLabel(tr("Incoming: --:--:--:--"), this); + m_liveLabel->setToolTip(tr("Last timecode received from the MIDI input")); + QFont mono = m_liveLabel->font(); + mono.setStyleHint(QFont::Monospace); + mono.setBold(true); + m_liveLabel->setFont(mono); + mainLayout->addWidget(m_liveLabel); + + // trigger table + m_table = new QTableWidget(0, 3, this); + m_table->setHorizontalHeaderLabels({tr("Timecode"), tr("Cue"), tr("Enabled")}); + m_table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch); + m_table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::ResizeToContents); + m_table->horizontalHeader()->setSectionResizeMode(2, QHeaderView::ResizeToContents); + m_table->verticalHeader()->setVisible(false); + m_table->setSelectionBehavior(QAbstractItemView::SelectRows); + m_table->setSelectionMode(QAbstractItemView::SingleSelection); + connect(m_table, &QTableWidget::cellChanged, this, &TimecodePanel::onCellChanged); + mainLayout->addWidget(m_table, 1); + + // add-trigger controls + QGroupBox* addGroup = new QGroupBox(tr("Add Trigger"), this); + QHBoxLayout* addLayout = new QHBoxLayout(addGroup); + + auto makeSpin = [this](int max, const QString& suffix) { + QSpinBox* spin = new QSpinBox(this); + spin->setRange(0, max); + spin->setSuffix(suffix); + spin->setAlignment(Qt::AlignRight); + return spin; + }; + m_hoursSpin = makeSpin(23, tr("h")); + m_minutesSpin = makeSpin(59, tr("m")); + m_secondsSpin = makeSpin(59, tr("s")); + m_framesSpin = makeSpin(29, tr("f")); + addLayout->addWidget(m_hoursSpin); + addLayout->addWidget(m_minutesSpin); + addLayout->addWidget(m_secondsSpin); + addLayout->addWidget(m_framesSpin); + + addLayout->addWidget(new QLabel(tr("Cue:"), this)); + m_cueSpin = new QDoubleSpinBox(this); + m_cueSpin->setRange(0.0, 99999.0); + m_cueSpin->setDecimals(2); + m_cueSpin->setSingleStep(1.0); + addLayout->addWidget(m_cueSpin); + + m_addButton = new QPushButton(Icons::listAdd(), tr("Add"), this); + connect(m_addButton, &QPushButton::clicked, this, &TimecodePanel::addTrigger); + addLayout->addWidget(m_addButton); + + mainLayout->addWidget(addGroup); + + // remove + QHBoxLayout* removeLayout = new QHBoxLayout(); + removeLayout->addStretch(); + m_removeButton = new QPushButton(Icons::listRemove(), tr("Remove Selected"), this); + connect(m_removeButton, &QPushButton::clicked, this, &TimecodePanel::removeTrigger); + removeLayout->addWidget(m_removeButton); + mainLayout->addLayout(removeLayout); +} + +void TimecodePanel::refresh() { + if (m_app) + m_triggers = m_app->timecodeTriggers(); + populateTable(); +} + +void TimecodePanel::populateTable() { + m_updatingUi = true; + m_table->setRowCount(0); + if (m_triggers) { + const QList list = m_triggers->triggers(); + m_table->setRowCount(list.size()); + for (int row = 0; row < list.size(); ++row) { + const TimecodeTrigger& t = list.at(row); + + QTableWidgetItem* tcItem = new QTableWidgetItem( + formatTimecode(t.hours, t.minutes, t.seconds, t.frames)); + tcItem->setData(TriggerIdRole, t.id); + tcItem->setFlags(tcItem->flags() & ~Qt::ItemIsEditable); + m_table->setItem(row, 0, tcItem); + + QTableWidgetItem* cueItem = new QTableWidgetItem(formatCue(t.cueNumber)); + cueItem->setFlags(cueItem->flags() & ~Qt::ItemIsEditable); + m_table->setItem(row, 1, cueItem); + + QTableWidgetItem* enItem = new QTableWidgetItem(); + enItem->setFlags((enItem->flags() | Qt::ItemIsUserCheckable) & ~Qt::ItemIsEditable); + enItem->setCheckState(t.enabled ? Qt::Checked : Qt::Unchecked); + m_table->setItem(row, 2, enItem); + } + } + m_updatingUi = false; +} + +QString TimecodePanel::rowTriggerId(int row) const { + QTableWidgetItem* item = m_table->item(row, 0); + return item ? item->data(TriggerIdRole).toString() : QString(); +} + +void TimecodePanel::addTrigger() { + if (!m_triggers) + return; + m_triggers->addTrigger(m_hoursSpin->value(), m_minutesSpin->value(), m_secondsSpin->value(), + m_framesSpin->value(), m_cueSpin->value()); + populateTable(); +} + +void TimecodePanel::removeTrigger() { + if (!m_triggers) + return; + const int row = m_table->currentRow(); + if (row < 0) + return; + const QString id = rowTriggerId(row); + if (id.isEmpty()) + return; + m_triggers->removeTrigger(id); + populateTable(); +} + +void TimecodePanel::onCellChanged(int row, int column) { + if (m_updatingUi || !m_triggers || column != 2) + return; + QTableWidgetItem* item = m_table->item(row, column); + if (!item) + return; + m_triggers->setTriggerEnabled(rowTriggerId(row), item->checkState() == Qt::Checked); +} + +void TimecodePanel::onIncomingTimecode(int hours, int minutes, int seconds, int frames) { + m_liveLabel->setText(tr("Incoming: %1").arg(formatTimecode(hours, minutes, seconds, frames))); +} + +void TimecodePanel::onTriggerFired(double cueNumber, const QString& triggerId) { + Q_UNUSED(cueNumber); + for (int row = 0; row < m_table->rowCount(); ++row) { + if (rowTriggerId(row) == triggerId) { + m_table->selectRow(row); + break; + } + } +} + +} // namespace OpenMix diff --git a/src/ui/TimecodePanel.h b/src/ui/TimecodePanel.h new file mode 100644 index 0000000..f67cf5f --- /dev/null +++ b/src/ui/TimecodePanel.h @@ -0,0 +1,56 @@ +#pragma once + +#include + +class QDoubleSpinBox; +class QLabel; +class QPushButton; +class QSpinBox; +class QTableWidget; + +namespace OpenMix { + +class Application; +class TimecodeTriggerList; + +// Manage timecode-to-cue triggers and show live incoming timecode. Backed by the +// Application-owned TimecodeTriggerList; a trigger fires its cue when playback +// timecode crosses its point. +class TimecodePanel : public QWidget { + Q_OBJECT + + public: + explicit TimecodePanel(Application* app, QWidget* parent = nullptr); + + public slots: + void refresh(); + + private slots: + void addTrigger(); + void removeTrigger(); + void onCellChanged(int row, int column); + void onIncomingTimecode(int hours, int minutes, int seconds, int frames); + void onTriggerFired(double cueNumber, const QString& triggerId); + + private: + void setupUi(); + void populateTable(); + [[nodiscard]] QString rowTriggerId(int row) const; + + Application* m_app; + TimecodeTriggerList* m_triggers = nullptr; + + QTableWidget* m_table; + QSpinBox* m_hoursSpin; + QSpinBox* m_minutesSpin; + QSpinBox* m_secondsSpin; + QSpinBox* m_framesSpin; + QDoubleSpinBox* m_cueSpin; + QPushButton* m_addButton; + QPushButton* m_removeButton; + QLabel* m_liveLabel; + + bool m_updatingUi = false; +}; + +} // namespace OpenMix From 15cb1769f5ccfced9227308c33b39168669faf4f Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 00:49:20 -0400 Subject: [PATCH 13/92] feat: cue clone, copy/paste/merge/swap, fill down, jump, and edit lock --- src/core/Cue.cpp | 88 +++++++++++++++++++++++++++++++ src/core/Cue.h | 9 ++++ src/ui/CueListView.cpp | 117 +++++++++++++++++++++++++++++++++++++++++ src/ui/CueListView.h | 19 ++++++- src/ui/MainWindow.cpp | 101 ++++++++++++++++++++++++++++++++++- src/ui/MainWindow.h | 13 +++++ tests/test_cue.cpp | 51 ++++++++++++++++++ 7 files changed, 394 insertions(+), 4 deletions(-) diff --git a/src/core/Cue.cpp b/src/core/Cue.cpp index f5cb09f..f8e5280 100644 --- a/src/core/Cue.cpp +++ b/src/core/Cue.cpp @@ -188,6 +188,94 @@ void Cue::copyDCAMappingFrom(const DCAMapping* showMapping) { } } +void Cue::mergeContentFrom(const Cue& other) { + // scalar content overwritten from the other cue + m_type = other.m_type; + m_autoFollow = other.m_autoFollow; + m_autoFollowDelay = other.m_autoFollowDelay; + m_autoFollowCondition = other.m_autoFollowCondition; + m_fadeTime = other.m_fadeTime; + m_fadeCurve = other.m_fadeCurve; + m_macroExecutionMode = other.m_macroExecutionMode; + m_gotoTarget = other.m_gotoTarget; + m_gotoAutoExecute = other.m_gotoAutoExecute; + m_stopBehavior = other.m_stopBehavior; + m_group = other.m_group; + m_qLabCue = other.m_qLabCue; + m_color = other.m_color; + m_skip = other.m_skip; + + // set/map content unites, other winning on collisions + m_targetedDCAs.unite(other.m_targetedDCAs); + for (auto it = other.m_dcaOverrides.begin(); it != other.m_dcaOverrides.end(); ++it) + m_dcaOverrides.insert(it.key(), it.value()); + for (auto it = other.m_channelPositions.begin(); it != other.m_channelPositions.end(); ++it) + m_channelPositions.insert(it.key(), it.value()); + for (auto it = other.m_channelProfiles.begin(); it != other.m_channelProfiles.end(); ++it) + m_channelProfiles.insert(it.key(), it.value()); + for (auto it = other.m_channelLevels.begin(); it != other.m_channelLevels.end(); ++it) + m_channelLevels.insert(it.key(), it.value()); + for (auto it = other.m_fxMutes.begin(); it != other.m_fxMutes.end(); ++it) + m_fxMutes.insert(it.key(), it.value()); + if (other.m_dcaChannelMapping) { + QMap> merged = m_dcaChannelMapping.value_or(QMap>()); + for (auto it = other.m_dcaChannelMapping->begin(); it != other.m_dcaChannelMapping->end(); + ++it) + merged.insert(it.key(), it.value()); + m_dcaChannelMapping = merged; + } + if (other.m_dcaBusMapping) { + QMap> merged = m_dcaBusMapping.value_or(QMap>()); + for (auto it = other.m_dcaBusMapping->begin(); it != other.m_dcaBusMapping->end(); ++it) + merged.insert(it.key(), it.value()); + m_dcaBusMapping = merged; + } + + // list content unites without duplicating + for (const QString& id : other.m_childCueIds) + if (!m_childCueIds.contains(id)) + m_childCueIds.append(id); + for (const QString& tag : other.m_tags) + if (!m_tags.contains(tag)) + m_tags.append(tag); + for (int snippet : other.m_snippets) + if (!m_snippets.contains(snippet)) + m_snippets.append(snippet); + + // parameter bag: other's keys overlay + for (auto it = other.m_parameters.begin(); it != other.m_parameters.end(); ++it) + m_parameters.insert(it.key(), it.value()); +} + +void Cue::swapContentWith(Cue& other) { + std::swap(m_type, other.m_type); + std::swap(m_autoFollow, other.m_autoFollow); + std::swap(m_autoFollowDelay, other.m_autoFollowDelay); + std::swap(m_autoFollowCondition, other.m_autoFollowCondition); + std::swap(m_fadeTime, other.m_fadeTime); + std::swap(m_fadeCurve, other.m_fadeCurve); + std::swap(m_targetedDCAs, other.m_targetedDCAs); + std::swap(m_dcaOverrides, other.m_dcaOverrides); + std::swap(m_dcaChannelMapping, other.m_dcaChannelMapping); + std::swap(m_dcaBusMapping, other.m_dcaBusMapping); + std::swap(m_childCueIds, other.m_childCueIds); + std::swap(m_macroExecutionMode, other.m_macroExecutionMode); + std::swap(m_gotoTarget, other.m_gotoTarget); + std::swap(m_gotoAutoExecute, other.m_gotoAutoExecute); + std::swap(m_stopBehavior, other.m_stopBehavior); + std::swap(m_group, other.m_group); + std::swap(m_tags, other.m_tags); + std::swap(m_qLabCue, other.m_qLabCue); + std::swap(m_channelPositions, other.m_channelPositions); + std::swap(m_parameters, other.m_parameters); + std::swap(m_channelProfiles, other.m_channelProfiles); + std::swap(m_channelLevels, other.m_channelLevels); + std::swap(m_fxMutes, other.m_fxMutes); + std::swap(m_snippets, other.m_snippets); + std::swap(m_color, other.m_color); + std::swap(m_skip, other.m_skip); +} + QJsonObject Cue::toJson() const { QJsonObject json; json["id"] = m_id; diff --git a/src/core/Cue.h b/src/core/Cue.h index bda000a..d67f7dd 100644 --- a/src/core/Cue.h +++ b/src/core/Cue.h @@ -204,6 +204,15 @@ class Cue { [[nodiscard]] bool skip() const noexcept { return m_skip; } void setSkip(bool skip) { m_skip = skip; } + // additively merge another cue's playable content into this one (maps and + // lists unite with the other winning on key collisions; scalar content is + // overwritten). Identity is preserved: id, number, name, notes stay. + void mergeContentFrom(const Cue& other); + + // exchange all playable content with another cue; each keeps its own + // identity (id, number, name, notes). + void swapContentWith(Cue& other); + QJsonObject toJson() const; [[nodiscard]] static Cue fromJson(const QJsonObject& json); diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index 3ea9aa7..21b7a0d 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -274,6 +274,123 @@ void CueListView::duplicateSelectedCue() { m_filterBar->updateFilterOptions(); } +void CueListView::selectSourceRow(int sourceRow) { + if (sourceRow < 0) + return; + QModelIndex sourceIndex = m_model->index(sourceRow, 0); + QModelIndex proxyIndex = m_proxyModel->mapFromSource(sourceIndex); + if (proxyIndex.isValid()) + m_tableView->selectRow(proxyIndex.row()); +} + +void CueListView::insertCueAt(int index, const Cue& cue) { + CueList* cueList = m_app->show()->cueList(); + const int clamped = std::clamp(index, 0, cueList->count()); + m_app->undoStack()->push(new AddCueCommand(cueList, cue, clamped)); + cueList->insertCue(clamped, cue); + selectSourceRow(clamped); + m_filterBar->updateFilterOptions(); +} + +// midpoint number that keeps the clone in order between a cue and its successor +static double interpolatedNumber(const CueList* cueList, int idx) { + const double a = cueList->at(idx).number(); + const double b = (idx + 1 < cueList->count()) ? cueList->at(idx + 1).number() : a + 1.0; + return b > a ? (a + b) / 2.0 : a + 0.1; +} + +void CueListView::cloneCueAfter() { + int idx = selectedCueIndex(); + if (idx < 0) + return; + CueList* cueList = m_app->show()->cueList(); + Cue clone = cueList->at(idx); + clone.regenerateId(); + clone.setNumber(interpolatedNumber(cueList, idx)); + insertCueAt(idx + 1, clone); +} + +void CueListView::copySelectedCue() { + int idx = selectedCueIndex(); + if (idx < 0) + return; + m_clipboard = m_app->show()->cueList()->at(idx); +} + +void CueListView::pasteCue() { + if (!m_clipboard) + return; + CueList* cueList = m_app->show()->cueList(); + Cue pasted = *m_clipboard; + pasted.regenerateId(); + + int idx = selectedCueIndex(); + if (idx < 0) { + pasted.setNumber(cueList->nextCueNumber()); + m_app->undoStack()->push(new AddCueCommand(cueList, pasted)); + cueList->addCue(pasted); + selectSourceRow(cueList->count() - 1); + m_filterBar->updateFilterOptions(); + return; + } + pasted.setNumber(interpolatedNumber(cueList, idx)); + insertCueAt(idx + 1, pasted); +} + +void CueListView::pasteCueMerge() { + int idx = selectedCueIndex(); + if (!m_clipboard || idx < 0) + return; + CueList* cueList = m_app->show()->cueList(); + Cue oldCue = cueList->at(idx); + Cue newCue = oldCue; + newCue.mergeContentFrom(*m_clipboard); + m_app->undoStack()->push(new EditCueCommand(cueList, idx, oldCue, newCue)); + cueList->updateCue(idx, newCue); +} + +void CueListView::pasteCueSwap() { + int idx = selectedCueIndex(); + if (!m_clipboard || idx < 0) + return; + CueList* cueList = m_app->show()->cueList(); + Cue oldCue = cueList->at(idx); + Cue newCue = oldCue; + Cue stored = *m_clipboard; + newCue.swapContentWith(stored); + m_app->undoStack()->push(new EditCueCommand(cueList, idx, oldCue, newCue)); + cueList->updateCue(idx, newCue); + m_clipboard = stored; // clipboard keeps the content swapped out of the cue +} + +void CueListView::fillDown() { + int idx = selectedCueIndex(); + CueList* cueList = m_app->show()->cueList(); + if (idx < 0 || idx + 1 >= cueList->count()) + return; + Cue source = cueList->at(idx); + Cue oldCue = cueList->at(idx + 1); + Cue newCue = oldCue; + newCue.mergeContentFrom(source); + m_app->undoStack()->push(new EditCueCommand(cueList, idx + 1, oldCue, newCue)); + cueList->updateCue(idx + 1, newCue); + selectSourceRow(idx + 1); +} + +void CueListView::jumpToSelectedCue() { + int idx = selectedCueIndex(); + if (idx < 0) + return; + m_app->playbackEngine()->setStandbyIndex(idx); +} + +void CueListView::setEditingLocked(bool locked) { + m_tableView->setEditTriggers(locked ? QAbstractItemView::NoEditTriggers + : QAbstractItemView::DoubleClicked | + QAbstractItemView::EditKeyPressed | + QAbstractItemView::AnyKeyPressed); +} + void CueListView::onSelectionChanged() { emit cueSelected(selectedCueIndex()); } void CueListView::onCueReordered(int fromIndex, int toIndex) { diff --git a/src/ui/CueListView.h b/src/ui/CueListView.h index a07426f..2df9673 100644 --- a/src/ui/CueListView.h +++ b/src/ui/CueListView.h @@ -1,14 +1,15 @@ #pragma once +#include "core/Cue.h" #include #include #include #include +#include namespace OpenMix { class Application; -class Cue; class CueTableModel; class CueFilterProxyModel; class CueFilterBar; @@ -37,7 +38,17 @@ class CueListView : public QWidget { public slots: void addNewCue(); void deleteSelectedCue(); - void duplicateSelectedCue(); + void duplicateSelectedCue(); // clone the selected cue to the end of the list + void cloneCueAfter(); // clone the selected cue in place, just after it + void copySelectedCue(); + void pasteCue(); // insert the clipboard cue as a new cue after selection + void pasteCueMerge(); // merge clipboard content into the selected cue + void pasteCueSwap(); // exchange content between clipboard and selected cue + void fillDown(); // copy the selected cue's content into the next cue + void jumpToSelectedCue(); // set the selected cue as standby without firing + void setEditingLocked(bool locked); // make the cue table read-only + + [[nodiscard]] bool hasClipboardCue() const { return m_clipboard.has_value(); } signals: void cueSelected(int index); @@ -58,6 +69,10 @@ class CueListView : public QWidget { void createActions(); void editNextCell(bool forward); QModelIndex nextEditableIndex(const QModelIndex& current, bool forward) const; + void insertCueAt(int index, const Cue& cue); // undoable insert + select + void selectSourceRow(int sourceRow); + + std::optional m_clipboard; // copied cue for paste operations Application* m_app; QTableView* m_tableView; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 2c69bc8..ba39a32 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -38,6 +38,7 @@ #include #include #include +#include #include #include #include @@ -141,6 +142,54 @@ void MainWindow::createActions() { m_redoAction->setIcon(Icons::editRedo()); m_redoAction->setToolTip(tr("Redo the last undone action (Ctrl+Y)")); + m_cloneCueAction = new QAction(tr("Clo&ne Cue"), this); + m_cloneCueAction->setToolTip(tr("Clone the selected cue in place, just after it")); + connect(m_cloneCueAction, &QAction::triggered, this, [this]() { m_cueListView->cloneCueAfter(); }); + + m_cloneToEndAction = new QAction(tr("Clone Cue to &End"), this); + m_cloneToEndAction->setToolTip(tr("Clone the selected cue to the end of the list")); + connect(m_cloneToEndAction, &QAction::triggered, this, + [this]() { m_cueListView->duplicateSelectedCue(); }); + + m_copyCueAction = new QAction(tr("&Copy Cue"), this); + m_copyCueAction->setShortcut(QKeySequence::Copy); + m_copyCueAction->setToolTip(tr("Copy the selected cue (Ctrl+C)")); + connect(m_copyCueAction, &QAction::triggered, this, + [this]() { m_cueListView->copySelectedCue(); updateActions(); }); + + m_pasteCueAction = new QAction(tr("&Paste Cue"), this); + m_pasteCueAction->setShortcut(QKeySequence::Paste); + m_pasteCueAction->setToolTip(tr("Paste the copied cue as a new cue (Ctrl+V)")); + connect(m_pasteCueAction, &QAction::triggered, this, [this]() { m_cueListView->pasteCue(); }); + + m_pasteMergeAction = new QAction(tr("Paste &Merge"), this); + m_pasteMergeAction->setToolTip(tr("Merge the copied cue's content into the selected cue")); + connect(m_pasteMergeAction, &QAction::triggered, this, + [this]() { m_cueListView->pasteCueMerge(); }); + + m_pasteSwapAction = new QAction(tr("Paste S&wap"), this); + m_pasteSwapAction->setToolTip(tr("Exchange content between the copied cue and the selected cue")); + connect(m_pasteSwapAction, &QAction::triggered, this, + [this]() { m_cueListView->pasteCueSwap(); }); + + m_fillDownAction = new QAction(tr("Fill &Down"), this); + m_fillDownAction->setToolTip(tr("Copy the selected cue's content into the next cue")); + connect(m_fillDownAction, &QAction::triggered, this, [this]() { m_cueListView->fillDown(); }); + + m_jumpToSelectedAction = new QAction(tr("&Jump to Selected Cue"), this); + m_jumpToSelectedAction->setToolTip(tr("Set the selected cue as standby without firing")); + connect(m_jumpToSelectedAction, &QAction::triggered, this, + [this]() { m_cueListView->jumpToSelectedCue(); }); + + m_jumpAction = new QAction(tr("Ju&mp..."), this); + m_jumpAction->setToolTip(tr("Set standby to a cue by number without firing")); + connect(m_jumpAction, &QAction::triggered, this, &MainWindow::showJumpDialog); + + m_lockEditingAction = new QAction(tr("&Lock Editing"), this); + m_lockEditingAction->setCheckable(true); + m_lockEditingAction->setToolTip(tr("Prevent cue edits during a performance")); + connect(m_lockEditingAction, &QAction::triggered, this, &MainWindow::toggleLockEditing); + m_goAction = new QAction(Icons::mediaPlay(), tr("&GO"), this); m_goAction->setShortcut(Qt::Key_Space); m_goAction->setToolTip(tr("Execute the next cue (Space)")); @@ -331,8 +380,20 @@ void MainWindow::createMenus() { m_editMenu->addSeparator(); m_editMenu->addAction(m_addCueAction); m_editMenu->addAction(m_deleteCueAction); + m_editMenu->addAction(m_cloneCueAction); + m_editMenu->addAction(m_cloneToEndAction); + m_editMenu->addSeparator(); + m_editMenu->addAction(m_copyCueAction); + m_editMenu->addAction(m_pasteCueAction); + m_editMenu->addAction(m_pasteMergeAction); + m_editMenu->addAction(m_pasteSwapAction); + m_editMenu->addAction(m_fillDownAction); m_editMenu->addSeparator(); m_editMenu->addAction(m_renumberAction); + m_editMenu->addAction(m_jumpToSelectedAction); + m_editMenu->addAction(m_jumpAction); + m_editMenu->addSeparator(); + m_editMenu->addAction(m_lockEditingAction); m_playbackMenu = menuBar()->addMenu(tr("&Playback")); m_playbackMenu->addAction(m_goAction); @@ -741,6 +802,28 @@ void MainWindow::renumberCues() { m_cueListView->refreshAll(); } +void MainWindow::showJumpDialog() { + CueList* list = m_app->show()->cueList(); + if (list->isEmpty()) + return; + bool ok = false; + const double number = QInputDialog::getDouble(this, tr("Jump to Cue"), tr("Cue number:"), 1.0, + 0.0, 99999.0, 2, &ok); + if (!ok) + return; + if (auto idx = list->indexOfNumber(number)) + m_app->playbackEngine()->setStandbyIndex(*idx); + else + QMessageBox::information(this, tr("Jump to Cue"), + tr("No cue numbered %1.").arg(number)); +} + +void MainWindow::toggleLockEditing() { + m_editingLocked = m_lockEditingAction->isChecked(); + m_cueListView->setEditingLocked(m_editingLocked); + updateActions(); +} + void MainWindow::go() { m_app->playbackEngine()->go(); } void MainWindow::stopPlayback() { m_app->playbackEngine()->stop(); } @@ -828,9 +911,23 @@ void MainWindow::updateTitle() { void MainWindow::updateActions() { bool hasCues = m_app->show()->cueList()->count() > 0; - m_deleteCueAction->setEnabled(hasCues && m_cueListView->selectedCueIndex() >= 0); - m_renumberAction->setEnabled(hasCues); + bool hasSelection = m_cueListView->selectedCueIndex() >= 0; + bool editable = !m_editingLocked; m_goAction->setEnabled(hasCues); + + // editing actions gate on selection and the editing lock + m_addCueAction->setEnabled(editable); + m_deleteCueAction->setEnabled(editable && hasCues && hasSelection); + m_renumberAction->setEnabled(editable && hasCues); + m_cloneCueAction->setEnabled(editable && hasSelection); + m_cloneToEndAction->setEnabled(editable && hasSelection); + m_copyCueAction->setEnabled(hasSelection); + m_pasteCueAction->setEnabled(editable && m_cueListView->hasClipboardCue()); + m_pasteMergeAction->setEnabled(editable && hasSelection && m_cueListView->hasClipboardCue()); + m_pasteSwapAction->setEnabled(editable && hasSelection && m_cueListView->hasClipboardCue()); + m_fillDownAction->setEnabled(editable && hasSelection); + m_jumpToSelectedAction->setEnabled(hasSelection); + m_jumpAction->setEnabled(hasCues); } void MainWindow::updateStatusBar() { diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index f992db7..1e6b528 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -51,6 +51,8 @@ class MainWindow : public QMainWindow { void addCue(); void deleteCue(); void renumberCues(); + void showJumpDialog(); + void toggleLockEditing(); // playback actions void go(); @@ -161,6 +163,17 @@ class MainWindow : public QMainWindow { QAction* m_renumberAction; QAction* m_undoAction; QAction* m_redoAction; + QAction* m_cloneCueAction; + QAction* m_cloneToEndAction; + QAction* m_copyCueAction; + QAction* m_pasteCueAction; + QAction* m_pasteMergeAction; + QAction* m_pasteSwapAction; + QAction* m_fillDownAction; + QAction* m_jumpToSelectedAction; + QAction* m_jumpAction; + QAction* m_lockEditingAction; + bool m_editingLocked = false; // playback actions QAction* m_goAction; diff --git a/tests/test_cue.cpp b/tests/test_cue.cpp index 5cabaf6..f787931 100644 --- a/tests/test_cue.cpp +++ b/tests/test_cue.cpp @@ -225,6 +225,57 @@ class TestCue : public QObject { QCOMPARE(stringToStopBehavior("stopandapply"), StopBehavior::StopAndApply); QCOMPARE(stringToStopBehavior("invalid"), StopBehavior::StopAndApply); // default } + + void testMergeContentPreservesIdentity() { + Cue target(5.0, "Target"); + target.setChannelProfile(1, "lead"); + Cue source(9.0, "Source"); + source.setNotes("src notes"); + source.setChannelProfile(2, "backup"); + source.setChannelLevel(3, 0.5); + source.setColor("#ff0000"); + + const QString keepId = target.id(); + target.mergeContentFrom(source); + + // identity preserved + QCOMPARE(target.id(), keepId); + QCOMPARE(target.number(), 5.0); + QCOMPARE(target.name(), QString("Target")); + QCOMPARE(target.notes(), QString()); // notes are identity, not merged + + // content united, other winning + QCOMPARE(target.channelProfiles().value(1), QString("lead")); + QCOMPARE(target.channelProfiles().value(2), QString("backup")); + QCOMPARE(target.channelLevels().value(3), 0.5); + QCOMPARE(target.color(), QString("#ff0000")); + } + + void testSwapContentKeepsIdentity() { + Cue a(1.0, "A"); + a.setColor("#111111"); + a.setChannelProfile(1, "aProfile"); + Cue b(2.0, "B"); + b.setColor("#222222"); + b.setChannelProfile(2, "bProfile"); + + const QString aId = a.id(); + const QString bId = b.id(); + a.swapContentWith(b); + + QCOMPARE(a.id(), aId); + QCOMPARE(a.number(), 1.0); + QCOMPARE(a.name(), QString("A")); + QCOMPARE(b.id(), bId); + QCOMPARE(b.number(), 2.0); + + // content exchanged + QCOMPARE(a.color(), QString("#222222")); + QCOMPARE(a.channelProfiles().value(2), QString("bProfile")); + QVERIFY(!a.channelProfiles().contains(1)); + QCOMPARE(b.color(), QString("#111111")); + QCOMPARE(b.channelProfiles().value(1), QString("aProfile")); + } }; QTEST_MAIN(TestCue) From 8ab5a3fb058a072b29347440f4fb45edc894092f Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 00:54:36 -0400 Subject: [PATCH 14/92] feat: import .tmix show files --- CMakeLists.txt | 4 + src/io/TmixImporter.cpp | 235 +++++++++++++++++++++++++++++++++++++ src/io/TmixImporter.h | 20 ++++ src/ui/MainWindow.cpp | 31 +++++ src/ui/MainWindow.h | 2 + tests/CMakeLists.txt | 24 ++++ tests/test_tmix_import.cpp | 90 ++++++++++++++ 7 files changed, 406 insertions(+) create mode 100644 src/io/TmixImporter.cpp create mode 100644 src/io/TmixImporter.h create mode 100644 tests/test_tmix_import.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index c43ea39..6ddfc5d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,6 +31,7 @@ find_package(Qt6 REQUIRED COMPONENTS Widgets Network Svg + Sql ) # icons @@ -164,6 +165,7 @@ set(SOURCES src/io/ProjectFile.cpp src/io/AutosaveManager.cpp src/io/CrashRecovery.cpp + src/io/TmixImporter.cpp src/midi/MidiControlMapping.cpp src/midi/MidiInputManager.cpp @@ -266,6 +268,7 @@ set(HEADERS src/io/ProjectFile.h src/io/AutosaveManager.h src/io/CrashRecovery.h + src/io/TmixImporter.h src/midi/MidiControlMapping.h src/midi/MidiInputManager.h @@ -303,6 +306,7 @@ target_link_libraries(${PROJECT_NAME} PRIVATE Qt6::Widgets Qt6::Network Qt6::Svg + Qt6::Sql qlementine-icons ) diff --git a/src/io/TmixImporter.cpp b/src/io/TmixImporter.cpp new file mode 100644 index 0000000..2b4de2c --- /dev/null +++ b/src/io/TmixImporter.cpp @@ -0,0 +1,235 @@ +#include "TmixImporter.h" +#include "core/Actor.h" +#include "core/ActorProfileLibrary.h" +#include "core/Cue.h" +#include "core/CueList.h" +#include "core/CueZero.h" +#include "core/DCAMapping.h" +#include "core/Ensemble.h" +#include "core/Position.h" +#include "core/Show.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace OpenMix { + +namespace { + +QList parseIntList(const QString& text) { + QList out; + for (const QString& tok : text.split(QRegularExpression("[,;\\s]+"), Qt::SkipEmptyParts)) { + bool ok = false; + int v = tok.toInt(&ok); + if (ok) + out.append(v); + } + return out; +} + +// parse a compact per-channel map: "ch=value,ch=value" (also accepts ':' and ';') +QList> parsePairs(const QString& text) { + QList> out; + for (const QString& tok : text.split(QRegularExpression("[,;]+"), Qt::SkipEmptyParts)) { + const QStringList kv = tok.split(QRegularExpression("[=:]")); + if (kv.size() >= 2) { + bool ok = false; + int ch = kv.at(0).trimmed().toInt(&ok); + if (ok) + out.append({ch, kv.at(1).trimmed()}); + } + } + return out; +} + +bool truthy(const QString& v) { + return v == "1" || v.compare("true", Qt::CaseInsensitive) == 0; +} + +QString dcaColumn(int dca, const char* suffix) { + return QString("dca%1%2").arg(dca, 2, 10, QChar('0')).arg(suffix); +} + +void clearShow(Show* show) { + show->cueList()->clear(); + show->actorProfileLibrary()->clear(); + show->positionLibrary()->clear(); + show->cueZero()->clear(); + show->dcaMapping()->clear(); + const auto ensembles = show->ensembleLibrary()->ensembles(); + for (const Ensemble& e : ensembles) + show->ensembleLibrary()->removeEnsemble(e.id()); +} + +} // namespace + +bool TmixImporter::import(const QString& path, Show* show, QString* error) { + if (!show) + return false; + + static int s_counter = 0; + const QString connName = QStringLiteral("tmiximport_%1").arg(++s_counter); + + bool ok = true; + { + QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE", connName); + db.setDatabaseName(path); + if (!db.open()) { + if (error) + *error = db.lastError().text(); + ok = false; + } else { + clearShow(show); + + QSqlQuery q(db); + + // positions: keep a map from the file's position id to the new id + QHash posIdMap; + if (q.exec("SELECT * FROM positions")) { + while (q.next()) { + const QSqlRecord r = q.record(); + Position p(r.value("name").toString(), r.value("shortName").toString()); + p.setDelay(r.value("delay").toDouble()); + p.setPan(r.value("pan").toDouble()); + if (r.indexOf("buses") >= 0) + p.setBuses(parseIntList(r.value("buses").toString())); + const QString newId = show->positionLibrary()->addPosition(p); + if (r.indexOf("id") >= 0) + posIdMap.insert(r.value("id").toInt(), newId); + } + } + + // channel voice presets become profile slots; map file id to slot name + QHash profileSlotMap; + if (q.exec("SELECT * FROM profiles")) { + while (q.next()) { + const QSqlRecord r = q.record(); + QString label = r.value("label").toString(); + if (label.isEmpty()) + label = r.value("name").toString(); + if (!label.isEmpty()) + show->actorProfileLibrary()->addSlot(label); + if (r.indexOf("id") >= 0) + profileSlotMap.insert(r.value("id").toInt(), label); + } + } + + // actors + if (q.exec("SELECT * FROM actors")) { + while (q.next()) { + const QSqlRecord r = q.record(); + Actor a(r.value("name").toString(), r.value("channel").toInt()); + a.setOrder(r.value("order").toInt()); + a.setActive(r.value("active").toInt() != 0); + show->actorProfileLibrary()->addActor(a); + } + } + + // ensembles + if (q.exec("SELECT * FROM ensembles")) { + while (q.next()) { + const QSqlRecord r = q.record(); + Ensemble e(r.value("name").toString()); + e.setChannels(parseIntList(r.value("channels").toString())); + show->ensembleLibrary()->addEnsemble(e); + } + } + + // cues + if (q.exec("SELECT * FROM cues")) { + while (q.next()) { + const QSqlRecord r = q.record(); + const double number = + r.value("number").toDouble() + r.value("point").toDouble() / 10.0; + Cue cue(number, r.value("name").toString()); + + QMap> dcaMap; + for (int i = 1; i <= 8; ++i) { + const QString cc = dcaColumn(i, "Channels"); + if (r.indexOf(cc) >= 0) { + const QList chs = parseIntList(r.value(cc).toString()); + if (!chs.isEmpty()) + dcaMap.insert(i, chs); + } + const QString lc = dcaColumn(i, "Label"); + if (r.indexOf(lc) >= 0) { + const QString label = r.value(lc).toString(); + if (!label.isEmpty()) { + DCAOverride ov; + ov.label = label; + cue.setDCAOverride(i, ov); + } + } + } + if (!dcaMap.isEmpty()) + cue.setDCAChannelMapping(dcaMap); + + if (r.indexOf("channelPositions") >= 0) { + for (const auto& pr : parsePairs(r.value("channelPositions").toString())) { + bool pok = false; + int pid = pr.second.toInt(&pok); + if (pok && posIdMap.contains(pid)) + cue.setChannelPosition(pr.first, posIdMap.value(pid)); + } + } + + if (r.indexOf("channelProfiles") >= 0) { + for (const auto& pr : parsePairs(r.value("channelProfiles").toString())) { + bool prok = false; + int prid = pr.second.toInt(&prok); + const QString slot = (prok && profileSlotMap.contains(prid)) + ? profileSlotMap.value(prid) + : pr.second; + if (!slot.isEmpty()) + cue.setChannelProfile(pr.first, slot); + } + } + + if (r.indexOf("fxMutes") >= 0) { + for (const auto& pr : parsePairs(r.value("fxMutes").toString())) + cue.setFxMute(pr.first, truthy(pr.second)); + } + + show->cueList()->addCue(cue); + } + } + + // config parameters mapped onto Show fields + QHash cfg; + if (q.exec("SELECT param, value FROM config")) { + while (q.next()) + cfg.insert(q.value(0).toString(), q.value(1).toString()); + } + if (cfg.contains("designer")) + show->setDesigner(cfg.value("designer")); + if (cfg.contains("dimDCAFaders")) + show->setDimDcaFaders(truthy(cfg.value("dimDCAFaders"))); + if (cfg.contains("selectOnSpill")) + show->setSelectOnSpill(truthy(cfg.value("selectOnSpill"))); + if (cfg.contains("consoleMuteDCAUnassign")) + show->setMuteDcaUnassign(truthy(cfg.value("consoleMuteDCAUnassign"))); + if (cfg.contains("suppressDCAMuteBackupSwitch")) + show->setSuppressBackupSwitch(truthy(cfg.value("suppressDCAMuteBackupSwitch"))); + if (truthy(cfg.value("gangLR"))) { + const QList lr = parseIntList(cfg.value("gangLRChannels")); + if (lr.size() >= 2) + show->setChannelGangs({qMakePair(lr.at(0), lr.at(1))}); + } + if (cfg.contains("gangLRName")) + show->setGangName(0, cfg.value("gangLRName")); + if (cfg.contains("gangLRColour")) + show->setGangColor(0, cfg.value("gangLRColour")); + + db.close(); + } + } + QSqlDatabase::removeDatabase(connName); + return ok; +} + +} // namespace OpenMix diff --git a/src/io/TmixImporter.h b/src/io/TmixImporter.h new file mode 100644 index 0000000..23ad83c --- /dev/null +++ b/src/io/TmixImporter.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +namespace OpenMix { + +class Show; + +// Imports a TheatreMix-format .tmix show file (a SQLite database) into a Show. +// The show is cleared first, then populated from the file's config, actors, +// profiles, positions, ensembles and cues tables. Best-effort: missing tables +// or columns are skipped rather than treated as errors. +class TmixImporter { + public: + // Populate `show` from the .tmix at `path`. Returns false and sets *error + // only when the database cannot be opened. + bool import(const QString& path, Show* show, QString* error = nullptr); +}; + +} // namespace OpenMix diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index ba39a32..969dcf2 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -25,6 +25,7 @@ #include "core/ShortcutManager.h" #include "core/Show.h" #include "io/ProjectFile.h" +#include "io/TmixImporter.h" #include "midi/MidiInputManager.h" #include "protocol/MixerProtocol.h" #include "theme/Icons.h" @@ -103,6 +104,10 @@ void MainWindow::createActions() { m_openAction->setToolTip(tr("Open an existing show (Ctrl+O)")); connect(m_openAction, &QAction::triggered, this, &MainWindow::openShow); + m_importTmixAction = new QAction(tr("&Import TheatreMix Show..."), this); + m_importTmixAction->setToolTip(tr("Import a .tmix show file")); + connect(m_importTmixAction, &QAction::triggered, this, &MainWindow::importTmixShow); + m_saveAction = new QAction(Icons::fileSave(), tr("&Save"), this); m_saveAction->setShortcut(QKeySequence::Save); m_saveAction->setToolTip(tr("Save the current show (Ctrl+S)")); @@ -366,6 +371,7 @@ void MainWindow::createMenus() { m_fileMenu = menuBar()->addMenu(tr("&File")); m_fileMenu->addAction(m_newAction); m_fileMenu->addAction(m_openAction); + m_fileMenu->addAction(m_importTmixAction); m_recentProjectsMenu = m_fileMenu->addMenu(tr("Open &Recent")); updateRecentProjectsMenu(); m_fileMenu->addSeparator(); @@ -748,6 +754,31 @@ void MainWindow::openShow() { updateRecentProjectsMenu(); } +void MainWindow::importTmixShow() { + if (!maybeSave()) + return; + + QString filePath = QFileDialog::getOpenFileName( + this, tr("Import TheatreMix Show"), QString(), tr("TheatreMix Show (*.tmix *.x32tc)")); + + if (filePath.isEmpty()) + return; + + QString error; + TmixImporter importer; + if (!importer.import(filePath, m_app->show(), &error)) { + QMessageBox::warning(this, tr("Error"), tr("Failed to import show:\n%1").arg(error)); + return; + } + + m_app->show()->setFilePath(QString()); // imported: force Save As to a native file + m_app->show()->setModified(true); + m_cueEditor->setCue(-1); + m_cueListView->refreshAll(); + updateTitle(); + updateStatusBar(); +} + void MainWindow::saveShow() { if (m_app->show()->filePath().isEmpty()) { saveShowAs(); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 1e6b528..87de60b 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -44,6 +44,7 @@ class MainWindow : public QMainWindow { // file menu actions void newShow(); void openShow(); + void importTmixShow(); void saveShow(); void saveShowAs(); @@ -153,6 +154,7 @@ class MainWindow : public QMainWindow { // file actions QAction* m_newAction; QAction* m_openAction; + QAction* m_importTmixAction; QAction* m_saveAction; QAction* m_saveAsAction; QAction* m_exitAction; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 20385f0..b2869e3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -345,3 +345,27 @@ target_include_directories(test_show_control PRIVATE ${CMAKE_SOURCE_DIR}/src ) add_test(NAME ShowControlTest COMMAND test_show_control) + +add_executable(test_tmix_import + test_tmix_import.cpp + ${CMAKE_SOURCE_DIR}/src/io/TmixImporter.cpp + ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp + ${CMAKE_SOURCE_DIR}/src/core/Show.cpp + ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp + ${CMAKE_SOURCE_DIR}/src/core/ActorProfile.cpp + ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp + ${CMAKE_SOURCE_DIR}/src/core/ActorProfileLibrary.cpp + ${CMAKE_SOURCE_DIR}/src/core/Position.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueZero.cpp + ${CMAKE_SOURCE_DIR}/src/core/Ensemble.cpp +) +target_link_libraries(test_tmix_import PRIVATE + Qt6::Core + Qt6::Sql + Qt6::Test +) +target_include_directories(test_tmix_import PRIVATE + ${CMAKE_SOURCE_DIR}/src +) +add_test(NAME TmixImportTest COMMAND test_tmix_import) diff --git a/tests/test_tmix_import.cpp b/tests/test_tmix_import.cpp new file mode 100644 index 0000000..fd631da --- /dev/null +++ b/tests/test_tmix_import.cpp @@ -0,0 +1,90 @@ +#include "core/ActorProfileLibrary.h" +#include "core/CueList.h" +#include "core/Position.h" +#include "core/Show.h" +#include "io/TmixImporter.h" +#include +#include +#include +#include + +using namespace OpenMix; + +class TestTmixImport : public QObject { + Q_OBJECT + + private slots: + void testImportPopulatesShow() { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = dir.filePath("show.tmix"); + + { + QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE", "tmixfixture"); + db.setDatabaseName(path); + QVERIFY(db.open()); + QSqlQuery q(db); + QVERIFY(q.exec("CREATE TABLE config (param TEXT PRIMARY KEY, value TEXT)")); + QVERIFY(q.exec("INSERT INTO config VALUES('designer','Jane Doe')")); + QVERIFY(q.exec("INSERT INTO config VALUES('selectOnSpill','1')")); + QVERIFY(q.exec("CREATE TABLE actors (id INTEGER PRIMARY KEY, channel INTEGER, " + "name TEXT, `order` INTEGER, active INTEGER)")); + QVERIFY(q.exec("INSERT INTO actors (channel,name,`order`,active) VALUES(3,'Alice',0,1)")); + QVERIFY(q.exec("CREATE TABLE positions (id INTEGER PRIMARY KEY, name TEXT, " + "shortName TEXT, delay NUMERIC, pan NUMERIC)")); + QVERIFY(q.exec("INSERT INTO positions (id,name,shortName,delay,pan) " + "VALUES(0,'Centre Stage','CS',12,-0.5)")); + QVERIFY(q.exec("CREATE TABLE cues (number INTEGER, point INTEGER, name TEXT, " + "dca01Channels TEXT, dca01Label TEXT)")); + QVERIFY(q.exec("INSERT INTO cues (number,point,name,dca01Channels,dca01Label) " + "VALUES(1,0,'Opening','3,4','Vox')")); + QVERIFY(q.exec("INSERT INTO cues (number,point,name) VALUES(2,0,'Second')")); + db.close(); + } + QSqlDatabase::removeDatabase("tmixfixture"); + + Show show; + TmixImporter importer; + QString err; + QVERIFY2(importer.import(path, &show, &err), qPrintable(err)); + + QCOMPARE(show.designer(), QString("Jane Doe")); + QVERIFY(show.selectOnSpill()); + + QCOMPARE(show.cueList()->count(), 2); + QCOMPARE(show.cueList()->at(0).name(), QString("Opening")); + QCOMPARE(show.cueList()->at(0).number(), 1.0); + QVERIFY(show.cueList()->at(0).hasCustomDCAMapping()); + QCOMPARE(show.cueList()->at(0).dcaOverride(1).label.value_or(QString()), QString("Vox")); + + QCOMPARE(show.positionLibrary()->count(), 1); + QCOMPARE(show.positionLibrary()->positions().first().shortName(), QString("CS")); + + QCOMPARE(show.actorProfileLibrary()->actors().size(), 1); + QCOMPARE(show.actorProfileLibrary()->actors().first().name(), QString("Alice")); + QCOMPARE(show.actorProfileLibrary()->actors().first().channel(), 3); + } + + void testImportMissingFileFails() { + Show show; + TmixImporter importer; + QString err; + // opening a nonexistent database path still "opens" in sqlite (creates it); + // an unreadable path is the real failure. Assert import never crashes and + // leaves an empty show when there are no tables. + QTemporaryDir dir; + const QString path = dir.filePath("empty.tmix"); + { + QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE", "tmixempty"); + db.setDatabaseName(path); + QVERIFY(db.open()); + db.close(); + } + QSqlDatabase::removeDatabase("tmixempty"); + QVERIFY(importer.import(path, &show, &err)); + QCOMPARE(show.cueList()->count(), 0); + } +}; + +QTEST_MAIN(TestTmixImport) +#include "test_tmix_import.moc" From 8b4c20dc56d084cad90824489a34dcae911a5984 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 00:56:27 -0400 Subject: [PATCH 15/92] feat: edit history browser and cue-list CSV export --- src/ui/MainWindow.cpp | 59 +++++++++++++++++++++++++++++++++++++++++++ src/ui/MainWindow.h | 4 +++ 2 files changed, 63 insertions(+) diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 969dcf2..97dced3 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -39,8 +39,13 @@ #include #include #include +#include +#include #include #include +#include +#include +#include #include #include #include @@ -281,6 +286,14 @@ void MainWindow::createActions() { m_cueZeroAction->setToolTip(tr("Edit the base/reset state recalled before the first cue")); connect(m_cueZeroAction, &QAction::triggered, this, &MainWindow::showCueZeroDialog); + m_editHistoryAction = new QAction(tr("Edit &History"), this); + m_editHistoryAction->setToolTip(tr("Browse and step through the edit history")); + connect(m_editHistoryAction, &QAction::triggered, this, &MainWindow::showEditHistoryDialog); + + m_exportCsvAction = new QAction(tr("&Export to CSV..."), this); + m_exportCsvAction->setToolTip(tr("Export the cue list to a CSV file")); + connect(m_exportCsvAction, &QAction::triggered, this, &MainWindow::exportCuesToCsv); + m_showLogViewerAction = new QAction(tr("Application &Log..."), this); m_showLogViewerAction->setShortcut(Qt::Key_F8); m_showLogViewerAction->setToolTip(tr("Show application log (F8)")); @@ -422,6 +435,8 @@ void MainWindow::createMenus() { m_viewMenu->addSeparator(); m_viewMenu->addAction(m_cueZeroAction); m_viewMenu->addSeparator(); + m_viewMenu->addAction(m_editHistoryAction); + m_viewMenu->addAction(m_exportCsvAction); m_viewMenu->addAction(m_showLogViewerAction); m_settingsMenu = menuBar()->addMenu(tr("&Settings")); @@ -1125,4 +1140,48 @@ void MainWindow::showLogViewerDialog() { dialog.exec(); } +void MainWindow::showEditHistoryDialog() { + QDialog dialog(this); + dialog.setWindowTitle(tr("Edit History")); + dialog.resize(360, 480); + QVBoxLayout* layout = new QVBoxLayout(&dialog); + QUndoView* view = new QUndoView(m_app->undoStack(), &dialog); + view->setEmptyLabel(tr("")); + layout->addWidget(view); + dialog.exec(); +} + +void MainWindow::exportCuesToCsv() { + QString filePath = QFileDialog::getSaveFileName(this, tr("Export to CSV"), QString(), + tr("CSV Files (*.csv)")); + if (filePath.isEmpty()) + return; + if (!filePath.endsWith(".csv", Qt::CaseInsensitive)) + filePath += ".csv"; + + QFile file(filePath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + QMessageBox::warning(this, tr("Error"), tr("Could not write %1").arg(filePath)); + return; + } + + // quote a field and escape embedded quotes per RFC 4180 + auto csv = [](const QString& s) { + QString v = s; + v.replace('"', "\"\""); + return QString("\"%1\"").arg(v); + }; + + QTextStream out(&file); + out << "Number,Name,Type,Notes,Color,Skip\n"; + const CueList* list = m_app->show()->cueList(); + for (int i = 0; i < list->count(); ++i) { + const Cue& cue = list->at(i); + out << QString::number(cue.number(), 'f', 2) << ',' << csv(cue.name()) << ',' + << csv(cueTypeToString(cue.type())) << ',' << csv(cue.notes()) << ',' + << csv(cue.color()) << ',' << (cue.skip() ? "yes" : "no") << '\n'; + } + file.close(); +} + } // namespace OpenMix diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 87de60b..6d56753 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -91,6 +91,8 @@ class MainWindow : public QMainWindow { void showKeyboardShortcutsDialog(); void showSettingsDialog(); void showLogViewerDialog(); + void showEditHistoryDialog(); + void exportCuesToCsv(); // bubble bar interaction void onBubbleButtonClicked(const QString& id, bool checked); @@ -196,6 +198,8 @@ class MainWindow : public QMainWindow { QAction* m_showPositionAction; QAction* m_showTimecodeAction; QAction* m_cueZeroAction; + QAction* m_editHistoryAction; + QAction* m_exportCsvAction; QAction* m_showLogViewerAction; // settings actions From 58688160136bb12a089518770af4c5e30abcd1c9 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 01:00:02 -0400 Subject: [PATCH 16/92] feat: channel utilisation report --- CMakeLists.txt | 4 ++ src/core/ChannelUtilisation.cpp | 61 ++++++++++++++++++++++++++++ src/core/ChannelUtilisation.h | 21 ++++++++++ src/ui/ChannelUtilisationDialog.cpp | 63 +++++++++++++++++++++++++++++ src/ui/ChannelUtilisationDialog.h | 18 +++++++++ src/ui/MainWindow.cpp | 12 ++++++ src/ui/MainWindow.h | 2 + tests/CMakeLists.txt | 23 +++++++++++ tests/test_channel_utilisation.cpp | 59 +++++++++++++++++++++++++++ 9 files changed, 263 insertions(+) create mode 100644 src/core/ChannelUtilisation.cpp create mode 100644 src/core/ChannelUtilisation.h create mode 100644 src/ui/ChannelUtilisationDialog.cpp create mode 100644 src/ui/ChannelUtilisationDialog.h create mode 100644 tests/test_channel_utilisation.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 6ddfc5d..de8c5b2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -100,6 +100,7 @@ set(SOURCES src/core/FadeEngine.cpp src/core/Position.cpp src/core/CueZero.cpp + src/core/ChannelUtilisation.cpp src/core/TimecodeTrigger.cpp src/core/ChannelMonitor.cpp src/core/ScribbleController.cpp @@ -126,6 +127,7 @@ set(SOURCES src/ui/PositionPanel.cpp src/ui/TimecodePanel.cpp src/ui/CueZeroDialog.cpp + src/ui/ChannelUtilisationDialog.cpp src/ui/PopOutWindow.cpp src/ui/BubbleButton.cpp src/ui/BubbleBar.cpp @@ -201,6 +203,7 @@ set(HEADERS src/core/FadeEngine.h src/core/Position.h src/core/CueZero.h + src/core/ChannelUtilisation.h src/core/TimecodeTrigger.h src/core/ChannelMonitor.h src/core/ScribbleController.h @@ -228,6 +231,7 @@ set(HEADERS src/ui/PositionPanel.h src/ui/TimecodePanel.h src/ui/CueZeroDialog.h + src/ui/ChannelUtilisationDialog.h src/ui/PopOutWindow.h src/ui/BubbleButton.h src/ui/BubbleBar.h diff --git a/src/core/ChannelUtilisation.cpp b/src/core/ChannelUtilisation.cpp new file mode 100644 index 0000000..ade4ed9 --- /dev/null +++ b/src/core/ChannelUtilisation.cpp @@ -0,0 +1,61 @@ +#include "ChannelUtilisation.h" +#include "Cue.h" +#include "CueList.h" +#include "DCAMapping.h" +#include "Show.h" + +#include +#include +#include + +namespace OpenMix { + +QList computeChannelUtilisation(const Show* show) { + QList result; + if (!show) + return result; + + const CueList* cues = show->cueList(); + const QMap> showMapping = show->dcaMapping()->channelAssignments(); + + // channel -> ordered, de-duplicated cue numbers + QMap> byChannel; + + auto note = [&byChannel](int channel, double cueNumber) { + QList& list = byChannel[channel]; + if (list.isEmpty() || list.last() != cueNumber) + list.append(cueNumber); + }; + + for (int i = 0; i < cues->count(); ++i) { + const Cue& cue = cues->at(i); + const double number = cue.number(); + + QSet channels; + + const QMap>& mapping = + cue.hasCustomDCAMapping() ? cue.dcaChannelMapping() : showMapping; + for (auto it = mapping.begin(); it != mapping.end(); ++it) + for (int ch : it.value()) + channels.insert(ch); + + for (int ch : cue.channelProfiles().keys()) + channels.insert(ch); + for (int ch : cue.channelLevels().keys()) + channels.insert(ch); + for (int ch : cue.channelPositions().keys()) + channels.insert(ch); + + for (int ch : channels) + note(ch, number); + } + + result.reserve(byChannel.size()); + for (auto it = byChannel.begin(); it != byChannel.end(); ++it) { + std::sort(it.value().begin(), it.value().end()); + result.append({it.key(), it.value()}); + } + return result; +} + +} // namespace OpenMix diff --git a/src/core/ChannelUtilisation.h b/src/core/ChannelUtilisation.h new file mode 100644 index 0000000..ff5bb21 --- /dev/null +++ b/src/core/ChannelUtilisation.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace OpenMix { + +class Show; + +// One input channel and the cue numbers that reference it. +struct ChannelUsage { + int channel = 0; + QList cueNumbers; // cues that touch this channel, in list order +}; + +// Compute, for every input channel referenced anywhere in the show, the list of +// cues that use it. A cue uses a channel when the channel appears in the cue's +// effective DCA mapping (its own custom mapping, else the show mapping) or in the +// cue's per-channel profile / level / position assignments. Sorted by channel. +[[nodiscard]] QList computeChannelUtilisation(const Show* show); + +} // namespace OpenMix diff --git a/src/ui/ChannelUtilisationDialog.cpp b/src/ui/ChannelUtilisationDialog.cpp new file mode 100644 index 0000000..44d1789 --- /dev/null +++ b/src/ui/ChannelUtilisationDialog.cpp @@ -0,0 +1,63 @@ +#include "ChannelUtilisationDialog.h" +#include "app/Application.h" +#include "core/Actor.h" +#include "core/ActorProfileLibrary.h" +#include "core/ChannelUtilisation.h" +#include "core/Show.h" + +#include +#include +#include +#include +#include + +namespace OpenMix { + +ChannelUtilisationDialog::ChannelUtilisationDialog(Application* app, QWidget* parent) + : QDialog(parent) { + setWindowTitle(tr("Channel Utilisation")); + resize(520, 520); + + QVBoxLayout* layout = new QVBoxLayout(this); + + QTableWidget* table = new QTableWidget(0, 4, this); + table->setHorizontalHeaderLabels({tr("Ch"), tr("Actor"), tr("Cues"), tr("Cue Numbers")}); + table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::ResizeToContents); + table->horizontalHeader()->setSectionResizeMode(2, QHeaderView::ResizeToContents); + table->horizontalHeader()->setSectionResizeMode(3, QHeaderView::Stretch); + table->verticalHeader()->setVisible(false); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->setSelectionBehavior(QAbstractItemView::SelectRows); + layout->addWidget(table); + + const ActorProfileLibrary* actors = app && app->show() ? app->show()->actorProfileLibrary() + : nullptr; + const QList usage = computeChannelUtilisation(app ? app->show() : nullptr); + table->setRowCount(usage.size()); + for (int row = 0; row < usage.size(); ++row) { + const ChannelUsage& u = usage.at(row); + + table->setItem(row, 0, new QTableWidgetItem(QString::number(u.channel))); + + QString actorName; + if (actors) { + if (const Actor* a = actors->actorForChannel(u.channel)) + actorName = a->name(); + } + table->setItem(row, 1, new QTableWidgetItem(actorName)); + + table->setItem(row, 2, new QTableWidgetItem(QString::number(u.cueNumbers.size()))); + + QStringList numbers; + for (double n : u.cueNumbers) + numbers.append(QString::number(n, 'g', 4)); + table->setItem(row, 3, new QTableWidgetItem(numbers.join(", "))); + } + + QDialogButtonBox* buttons = new QDialogButtonBox(QDialogButtonBox::Close, this); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + layout->addWidget(buttons); +} + +} // namespace OpenMix diff --git a/src/ui/ChannelUtilisationDialog.h b/src/ui/ChannelUtilisationDialog.h new file mode 100644 index 0000000..c136f87 --- /dev/null +++ b/src/ui/ChannelUtilisationDialog.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace OpenMix { + +class Application; + +// Read-only report: each referenced input channel, its assigned actor, and the +// cues that use it. Computed on open from the current show. +class ChannelUtilisationDialog : public QDialog { + Q_OBJECT + + public: + explicit ChannelUtilisationDialog(Application* app, QWidget* parent = nullptr); +}; + +} // namespace OpenMix diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 97dced3..b19fbab 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -4,6 +4,7 @@ #include "BubbleButton.h" #include "ConnectionPanel.h" #include "CueEditor.h" +#include "ChannelUtilisationDialog.h" #include "CueListView.h" #include "CueTableModel.h" #include "CueZeroDialog.h" @@ -294,6 +295,11 @@ void MainWindow::createActions() { m_exportCsvAction->setToolTip(tr("Export the cue list to a CSV file")); connect(m_exportCsvAction, &QAction::triggered, this, &MainWindow::exportCuesToCsv); + m_channelUtilisationAction = new QAction(tr("Channel &Utilisation"), this); + m_channelUtilisationAction->setToolTip(tr("Show which cues use each input channel")); + connect(m_channelUtilisationAction, &QAction::triggered, this, + &MainWindow::showChannelUtilisationDialog); + m_showLogViewerAction = new QAction(tr("Application &Log..."), this); m_showLogViewerAction->setShortcut(Qt::Key_F8); m_showLogViewerAction->setToolTip(tr("Show application log (F8)")); @@ -436,6 +442,7 @@ void MainWindow::createMenus() { m_viewMenu->addAction(m_cueZeroAction); m_viewMenu->addSeparator(); m_viewMenu->addAction(m_editHistoryAction); + m_viewMenu->addAction(m_channelUtilisationAction); m_viewMenu->addAction(m_exportCsvAction); m_viewMenu->addAction(m_showLogViewerAction); @@ -1151,6 +1158,11 @@ void MainWindow::showEditHistoryDialog() { dialog.exec(); } +void MainWindow::showChannelUtilisationDialog() { + ChannelUtilisationDialog dialog(m_app, this); + dialog.exec(); +} + void MainWindow::exportCuesToCsv() { QString filePath = QFileDialog::getSaveFileName(this, tr("Export to CSV"), QString(), tr("CSV Files (*.csv)")); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 6d56753..590d865 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -93,6 +93,7 @@ class MainWindow : public QMainWindow { void showLogViewerDialog(); void showEditHistoryDialog(); void exportCuesToCsv(); + void showChannelUtilisationDialog(); // bubble bar interaction void onBubbleButtonClicked(const QString& id, bool checked); @@ -200,6 +201,7 @@ class MainWindow : public QMainWindow { QAction* m_cueZeroAction; QAction* m_editHistoryAction; QAction* m_exportCsvAction; + QAction* m_channelUtilisationAction; QAction* m_showLogViewerAction; // settings actions diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b2869e3..e24b56c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -369,3 +369,26 @@ target_include_directories(test_tmix_import PRIVATE ${CMAKE_SOURCE_DIR}/src ) add_test(NAME TmixImportTest COMMAND test_tmix_import) + +add_executable(test_channel_utilisation + test_channel_utilisation.cpp + ${CMAKE_SOURCE_DIR}/src/core/ChannelUtilisation.cpp + ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp + ${CMAKE_SOURCE_DIR}/src/core/Show.cpp + ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp + ${CMAKE_SOURCE_DIR}/src/core/ActorProfile.cpp + ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp + ${CMAKE_SOURCE_DIR}/src/core/ActorProfileLibrary.cpp + ${CMAKE_SOURCE_DIR}/src/core/Position.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueZero.cpp + ${CMAKE_SOURCE_DIR}/src/core/Ensemble.cpp +) +target_link_libraries(test_channel_utilisation PRIVATE + Qt6::Core + Qt6::Test +) +target_include_directories(test_channel_utilisation PRIVATE + ${CMAKE_SOURCE_DIR}/src +) +add_test(NAME ChannelUtilisationTest COMMAND test_channel_utilisation) diff --git a/tests/test_channel_utilisation.cpp b/tests/test_channel_utilisation.cpp new file mode 100644 index 0000000..300e2d6 --- /dev/null +++ b/tests/test_channel_utilisation.cpp @@ -0,0 +1,59 @@ +#include "core/ChannelUtilisation.h" +#include "core/Cue.h" +#include "core/CueList.h" +#include "core/DCAMapping.h" +#include "core/Show.h" +#include +#include + +using namespace OpenMix; + +class TestChannelUtilisation : public QObject { + Q_OBJECT + + static QMap counts(const QList& usage) { + QMap c; + for (const ChannelUsage& u : usage) + c.insert(u.channel, u.cueNumbers.size()); + return c; + } + + private slots: + void testCustomMappingAndPerChannel() { + Show show; + Cue c1(1.0, "One"); + QMap> m; + m.insert(1, {5, 6}); + c1.setDCAChannelMapping(m); + c1.setChannelProfile(7, "lead"); + show.cueList()->addCue(c1); + + Cue c2(2.0, "Two"); + c2.setChannelLevel(6, 0.5); + show.cueList()->addCue(c2); + + const QMap c = counts(computeChannelUtilisation(&show)); + QCOMPARE(c.value(5), 1); + QCOMPARE(c.value(6), 2); // c1 via DCA mapping, c2 via level override + QCOMPARE(c.value(7), 1); // c1 via profile + } + + void testShowMappingUsedWhenNoCustom() { + Show show; + show.dcaMapping()->assignChannelToDCA(3, 1); + Cue c(1.0, "One"); // no custom mapping -> falls back to show mapping + show.cueList()->addCue(c); + + const QMap counts = TestChannelUtilisation::counts(computeChannelUtilisation(&show)); + QCOMPARE(counts.value(3), 1); + } + + void testEmptyShow() { + Show show; + QVERIFY(computeChannelUtilisation(&show).isEmpty()); + QVERIFY(computeChannelUtilisation(nullptr).isEmpty()); + } +}; + +QTEST_MAIN(TestChannelUtilisation) +#include "test_channel_utilisation.moc" From b3d7466d06764f5058b8fc9cdaa47015201597da Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 01:01:34 -0400 Subject: [PATCH 17/92] feat: cue-table row size selector --- src/ui/CueListView.cpp | 4 ++++ src/ui/CueListView.h | 1 + src/ui/MainWindow.cpp | 21 +++++++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index 21b7a0d..cf7b540 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -384,6 +384,10 @@ void CueListView::jumpToSelectedCue() { m_app->playbackEngine()->setStandbyIndex(idx); } +void CueListView::setRowHeight(int pixels) { + m_tableView->verticalHeader()->setDefaultSectionSize(pixels); +} + void CueListView::setEditingLocked(bool locked) { m_tableView->setEditTriggers(locked ? QAbstractItemView::NoEditTriggers : QAbstractItemView::DoubleClicked | diff --git a/src/ui/CueListView.h b/src/ui/CueListView.h index 2df9673..9a63e25 100644 --- a/src/ui/CueListView.h +++ b/src/ui/CueListView.h @@ -47,6 +47,7 @@ class CueListView : public QWidget { void fillDown(); // copy the selected cue's content into the next cue void jumpToSelectedCue(); // set the selected cue as standby without firing void setEditingLocked(bool locked); // make the cue table read-only + void setRowHeight(int pixels); // cue-table row height [[nodiscard]] bool hasClipboardCue() const { return m_clipboard.has_value(); } diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index b19fbab..348f37c 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -40,6 +40,7 @@ #include #include #include +#include #include #include #include @@ -439,6 +440,26 @@ void MainWindow::createMenus() { m_viewMenu->addAction(m_showPositionAction); m_viewMenu->addAction(m_showTimecodeAction); m_viewMenu->addSeparator(); + + // cue-table row size + QMenu* rowSizeMenu = m_viewMenu->addMenu(tr("&Row Size")); + QActionGroup* rowSizeGroup = new QActionGroup(this); + struct RowSize { + const char* label; + int pixels; + }; + const RowSize rowSizes[] = {{"&Small", 22}, {"&Medium", 28}, {"&Large", 40}}; + for (const RowSize& rs : rowSizes) { + QAction* action = rowSizeMenu->addAction(tr(rs.label)); + action->setCheckable(true); + action->setChecked(rs.pixels == 28); // medium default + rowSizeGroup->addAction(action); + const int pixels = rs.pixels; + connect(action, &QAction::triggered, this, [this, pixels]() { + m_cueListView->setRowHeight(pixels); + }); + } + m_viewMenu->addSeparator(); m_viewMenu->addAction(m_cueZeroAction); m_viewMenu->addSeparator(); m_viewMenu->addAction(m_editHistoryAction); From f369a02b993a158fa3ca21a638239625870a3d82 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 01:06:30 -0400 Subject: [PATCH 18/92] feat: cue-table column visibility toggles --- src/ui/CueListView.cpp | 4 ++++ src/ui/CueListView.h | 1 + src/ui/MainWindow.cpp | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index cf7b540..b7a77eb 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -388,6 +388,10 @@ void CueListView::setRowHeight(int pixels) { m_tableView->verticalHeader()->setDefaultSectionSize(pixels); } +void CueListView::setColumnVisible(int column, bool visible) { + m_tableView->setColumnHidden(column, !visible); +} + void CueListView::setEditingLocked(bool locked) { m_tableView->setEditTriggers(locked ? QAbstractItemView::NoEditTriggers : QAbstractItemView::DoubleClicked | diff --git a/src/ui/CueListView.h b/src/ui/CueListView.h index 9a63e25..7c24dec 100644 --- a/src/ui/CueListView.h +++ b/src/ui/CueListView.h @@ -48,6 +48,7 @@ class CueListView : public QWidget { void jumpToSelectedCue(); // set the selected cue as standby without firing void setEditingLocked(bool locked); // make the cue table read-only void setRowHeight(int pixels); // cue-table row height + void setColumnVisible(int column, bool visible); [[nodiscard]] bool hasClipboardCue() const { return m_clipboard.has_value(); } diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 348f37c..f5c7867 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -459,6 +459,25 @@ void MainWindow::createMenus() { m_cueListView->setRowHeight(pixels); }); } + + // optional-column visibility + QMenu* columnsMenu = m_viewMenu->addMenu(tr("&Columns")); + struct ColumnToggle { + const char* label; + int column; + }; + const ColumnToggle columnToggles[] = {{"&Group", CueTableModel::ColGroup}, + {"&Tags", CueTableModel::ColTags}, + {"&Notes", CueTableModel::ColNotes}, + {"Colo&r", CueTableModel::ColColor}}; + for (const ColumnToggle& ct : columnToggles) { + QAction* action = columnsMenu->addAction(tr(ct.label)); + action->setCheckable(true); + action->setChecked(true); + const int column = ct.column; + connect(action, &QAction::toggled, this, + [this, column](bool on) { m_cueListView->setColumnVisible(column, on); }); + } m_viewMenu->addSeparator(); m_viewMenu->addAction(m_cueZeroAction); m_viewMenu->addSeparator(); From ad04f1813bdba4157ac31a6dee7102515606cdb7 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 01:11:19 -0400 Subject: [PATCH 19/92] feat: spare-backup mic allocation model --- CMakeLists.txt | 2 + src/core/Show.cpp | 14 ++++- src/core/Show.h | 5 ++ src/core/SpareBackup.cpp | 97 +++++++++++++++++++++++++++++++++++ src/core/SpareBackup.h | 65 +++++++++++++++++++++++ tests/CMakeLists.txt | 19 +++++++ tests/test_actor_profiles.cpp | 2 +- tests/test_ensembles.cpp | 2 +- tests/test_spare_backup.cpp | 69 +++++++++++++++++++++++++ 9 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 src/core/SpareBackup.cpp create mode 100644 src/core/SpareBackup.h create mode 100644 tests/test_spare_backup.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index de8c5b2..1e7af24 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -84,6 +84,7 @@ set(SOURCES src/core/Cue.cpp src/core/CueList.cpp src/core/Show.cpp + src/core/SpareBackup.cpp src/core/PlaybackEngine.cpp src/core/UndoCommands.cpp src/core/CueValidator.cpp @@ -186,6 +187,7 @@ set(HEADERS src/core/Cue.h src/core/CueList.h src/core/Show.h + src/core/SpareBackup.h src/core/PlaybackEngine.h src/core/UndoCommands.h src/core/CueValidator.h diff --git a/src/core/Show.cpp b/src/core/Show.cpp index 43cad10..fe02171 100644 --- a/src/core/Show.cpp +++ b/src/core/Show.cpp @@ -23,7 +23,8 @@ MixerConfig MixerConfig::fromJson(const QJsonObject& json) { Show::Show(QObject* parent) : QObject(parent), m_cueList(this), m_dcaMapping(this), m_actorProfileLibrary(this), - m_positionLibrary(this), m_ensembleLibrary(this), m_cueZero(this) { + m_positionLibrary(this), m_ensembleLibrary(this), m_cueZero(this), m_spareBackup(this) { + connect(&m_spareBackup, &SpareBackup::changed, this, &Show::checkModifiedState); connectCueListSignals(); connectDcaMappingSignals(); connectActorLibrarySignals(); @@ -140,13 +141,14 @@ void Show::newShow() { m_positionLibrary.clear(); m_ensembleLibrary.clear(); m_cueZero.clear(); + m_spareBackup.setSpareChannel(-1); m_channelGangs.clear(); m_isDirty = false; } QJsonObject Show::toJson() const { QJsonObject json; - json["version"] = "1.4"; + json["version"] = "1.5"; json["name"] = m_name; json["author"] = m_author; json["designer"] = m_designer; @@ -163,6 +165,7 @@ QJsonObject Show::toJson() const { json["positions"] = m_positionLibrary.toJson(); json["ensembles"] = m_ensembleLibrary.toJson(); json["cueZero"] = m_cueZero.toJson(); + json["spareBackup"] = m_spareBackup.toJson(); QJsonArray gangArray; for (const auto& gang : m_channelGangs) { @@ -239,6 +242,13 @@ void Show::fromJson(const QJsonObject& json) { m_cueZero.clear(); } + // spare-backup allocation (added in show version 1.5) + if (json.contains("spareBackup")) { + m_spareBackup.loadFromJson(json["spareBackup"].toObject()); + } else { + m_spareBackup.setSpareChannel(-1); + } + // ganged input-channel pairs m_channelGangs.clear(); if (json.contains("channelGangs")) { diff --git a/src/core/Show.h b/src/core/Show.h index 4f3a91f..f56f59d 100644 --- a/src/core/Show.h +++ b/src/core/Show.h @@ -6,6 +6,7 @@ #include "DCAMapping.h" #include "Ensemble.h" #include "Position.h" +#include "SpareBackup.h" #include #include #include @@ -74,6 +75,9 @@ class Show : public QObject { [[nodiscard]] CueZero* cueZero() { return &m_cueZero; } [[nodiscard]] const CueZero* cueZero() const { return &m_cueZero; } + [[nodiscard]] SpareBackup* spareBackup() { return &m_spareBackup; } + [[nodiscard]] const SpareBackup* spareBackup() const { return &m_spareBackup; } + // recall the show's Cue Zero base state onto a connected mixer void applyCueZero(MixerProtocol* mixer) const { m_cueZero.apply(mixer); } @@ -140,6 +144,7 @@ class Show : public QObject { PositionLibrary m_positionLibrary; EnsembleLibrary m_ensembleLibrary; CueZero m_cueZero; + SpareBackup m_spareBackup; bool m_isDirty = false; }; diff --git a/src/core/SpareBackup.cpp b/src/core/SpareBackup.cpp new file mode 100644 index 0000000..002cfcf --- /dev/null +++ b/src/core/SpareBackup.cpp @@ -0,0 +1,97 @@ +#include "SpareBackup.h" + +namespace OpenMix { + +SpareBackup::SpareBackup(QObject* parent) : QObject(parent) {} + +void SpareBackup::setSpareChannel(int channel) { + if (m_spareChannel == channel) + return; + m_spareChannel = channel; + // dropping the spare invalidates any allocation/switch + if (m_spareChannel < 0) { + m_allocatedChannel = -1; + setState(State::Inactive); + emit allocationChanged(m_allocatedChannel); + } + emit changed(); +} + +bool SpareBackup::allocateTo(int channel) { + if (m_spareChannel < 0 || channel < 0) + return false; + if (m_state == State::Active) // can't reallocate a live spare + return false; + if (m_allocatedChannel == channel) + return true; + m_allocatedChannel = channel; + setState(State::Inactive); + emit allocationChanged(m_allocatedChannel); + emit changed(); + return true; +} + +void SpareBackup::removeAllocation() { + if (m_allocatedChannel < 0 && m_state == State::Inactive) + return; + m_allocatedChannel = -1; + setState(State::Inactive); + emit allocationChanged(m_allocatedChannel); + emit changed(); +} + +void SpareBackup::switchNow() { + if (!isAllocated()) + return; + setState(State::Active); + emit changed(); +} + +void SpareBackup::switchLater() { + if (!isAllocated()) + return; + setState(State::Armed); + emit changed(); +} + +void SpareBackup::revert() { + if (m_state == State::Inactive) + return; + setState(State::Inactive); + emit changed(); +} + +void SpareBackup::onCueFired() { + if (m_state == State::Armed) + setState(State::Active); +} + +void SpareBackup::setState(State state) { + if (m_state == state) + return; + m_state = state; + emit stateChanged(m_state); +} + +QJsonObject SpareBackup::toJson() const { + QJsonObject json; + json["spareChannel"] = m_spareChannel; + json["allocatedChannel"] = m_allocatedChannel; + json["state"] = static_cast(m_state); + return json; +} + +void SpareBackup::loadFromJson(const QJsonObject& json) { + m_spareChannel = json.value("spareChannel").toInt(-1); + m_allocatedChannel = json.value("allocatedChannel").toInt(-1); + m_state = static_cast(json.value("state").toInt(0)); + if (m_spareChannel < 0) { + m_allocatedChannel = -1; + m_state = State::Inactive; + } + emit allocationChanged(m_allocatedChannel); + emit stateChanged(m_state); + emit changed(); +} + +} // namespace OpenMix diff --git a/src/core/SpareBackup.h b/src/core/SpareBackup.h new file mode 100644 index 0000000..9b8de1c --- /dev/null +++ b/src/core/SpareBackup.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include + +namespace OpenMix { + +// Spare-backup mic model: one reserved console channel that can be allocated to +// cover a failed input, then switched live so the covered actor's backup voice +// plays through the spare. Mirrors the allocate / switch-now / switch-later +// workflow. State only -- applying the voice to the mixer is the caller's job. +class SpareBackup : public QObject { + Q_OBJECT + + public: + enum class State { + Inactive, // primary channel live, spare idle + Armed, // switch queued for the next cue fire + Active // spare is live, covering the allocated channel + }; + + explicit SpareBackup(QObject* parent = nullptr); + + // the console channel reserved as the spare mic (-1 = none configured) + [[nodiscard]] int spareChannel() const noexcept { return m_spareChannel; } + void setSpareChannel(int channel); + + // the channel the spare currently covers (-1 = unallocated) + [[nodiscard]] int allocatedChannel() const noexcept { return m_allocatedChannel; } + [[nodiscard]] bool isAllocated() const noexcept { return m_allocatedChannel >= 0; } + + // allocate the spare to cover a channel. Fails if no spare is configured or + // the spare is currently active (can't reallocate a live spare). + bool allocateTo(int channel); + void removeAllocation(); + + [[nodiscard]] State state() const noexcept { return m_state; } + [[nodiscard]] bool isActive() const noexcept { return m_state == State::Active; } + + void switchNow(); // allocated -> Active immediately + void switchLater(); // allocated -> Armed (promotes on the next cue fire) + void revert(); // back to Inactive + + public slots: + // promotes an Armed switch to Active; no effect otherwise + void onCueFired(); + + public: + QJsonObject toJson() const; + void loadFromJson(const QJsonObject& json); + + signals: + void changed(); + void stateChanged(State state); + void allocationChanged(int channel); + + private: + void setState(State state); + + int m_spareChannel = -1; + int m_allocatedChannel = -1; + State m_state = State::Inactive; +}; + +} // namespace OpenMix diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e24b56c..01f4da1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -30,6 +30,7 @@ add_test(NAME CueListTest COMMAND test_cuelist) add_executable(test_show test_show.cpp ${CMAKE_SOURCE_DIR}/src/core/Show.cpp + ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp @@ -58,6 +59,7 @@ add_executable(test_actor_profiles ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp ${CMAKE_SOURCE_DIR}/src/core/Show.cpp + ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp ${CMAKE_SOURCE_DIR}/src/core/Ensemble.cpp ${CMAKE_SOURCE_DIR}/src/core/Position.cpp ${CMAKE_SOURCE_DIR}/src/core/CueZero.cpp @@ -286,6 +288,7 @@ add_executable(test_ensembles test_ensembles.cpp ${CMAKE_SOURCE_DIR}/src/core/Ensemble.cpp ${CMAKE_SOURCE_DIR}/src/core/Show.cpp + ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp @@ -323,6 +326,7 @@ add_executable(test_show_control ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp ${CMAKE_SOURCE_DIR}/src/core/Show.cpp + ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp ${CMAKE_SOURCE_DIR}/src/core/CueValidator.cpp ${CMAKE_SOURCE_DIR}/src/core/PlaybackGuard.cpp @@ -352,6 +356,7 @@ add_executable(test_tmix_import ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp ${CMAKE_SOURCE_DIR}/src/core/Show.cpp + ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp ${CMAKE_SOURCE_DIR}/src/core/ActorProfile.cpp ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp @@ -376,6 +381,7 @@ add_executable(test_channel_utilisation ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp ${CMAKE_SOURCE_DIR}/src/core/Show.cpp + ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp ${CMAKE_SOURCE_DIR}/src/core/ActorProfile.cpp ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp @@ -392,3 +398,16 @@ target_include_directories(test_channel_utilisation PRIVATE ${CMAKE_SOURCE_DIR}/src ) add_test(NAME ChannelUtilisationTest COMMAND test_channel_utilisation) + +add_executable(test_spare_backup + test_spare_backup.cpp + ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp +) +target_link_libraries(test_spare_backup PRIVATE + Qt6::Core + Qt6::Test +) +target_include_directories(test_spare_backup PRIVATE + ${CMAKE_SOURCE_DIR}/src +) +add_test(NAME SpareBackupTest COMMAND test_spare_backup) diff --git a/tests/test_actor_profiles.cpp b/tests/test_actor_profiles.cpp index f2a9dc9..464bedd 100644 --- a/tests/test_actor_profiles.cpp +++ b/tests/test_actor_profiles.cpp @@ -174,7 +174,7 @@ class TestActorProfiles : public QObject { QVERIFY(spy.count() >= 1); const QJsonObject json = show.toJson(); - QCOMPARE(json["version"].toString(), QString("1.4")); + QCOMPARE(json["version"].toString(), QString("1.5")); Show loaded; loaded.fromJson(json); diff --git a/tests/test_ensembles.cpp b/tests/test_ensembles.cpp index 96ca658..fada37e 100644 --- a/tests/test_ensembles.cpp +++ b/tests/test_ensembles.cpp @@ -113,7 +113,7 @@ class TestEnsembles : public QObject { QVERIFY(spy.count() >= 1); const QJsonObject json = show.toJson(); - QCOMPARE(json["version"].toString(), QString("1.4")); + QCOMPARE(json["version"].toString(), QString("1.5")); Show loaded; loaded.fromJson(json); diff --git a/tests/test_spare_backup.cpp b/tests/test_spare_backup.cpp new file mode 100644 index 0000000..5d85585 --- /dev/null +++ b/tests/test_spare_backup.cpp @@ -0,0 +1,69 @@ +#include "core/SpareBackup.h" +#include +#include + +using namespace OpenMix; + +class TestSpareBackup : public QObject { + Q_OBJECT + + private slots: + void testAllocateRequiresSpare() { + SpareBackup sb; + QVERIFY(!sb.allocateTo(5)); // no spare configured yet + sb.setSpareChannel(40); + QVERIFY(sb.allocateTo(5)); + QCOMPARE(sb.allocatedChannel(), 5); + QVERIFY(sb.isAllocated()); + QCOMPARE(sb.state(), SpareBackup::State::Inactive); + } + + void testSwitchNowBlocksReallocation() { + SpareBackup sb; + sb.setSpareChannel(40); + sb.allocateTo(5); + sb.switchNow(); + QVERIFY(sb.isActive()); + QVERIFY(!sb.allocateTo(6)); // live spare can't be reallocated + QCOMPARE(sb.allocatedChannel(), 5); + sb.revert(); + QCOMPARE(sb.state(), SpareBackup::State::Inactive); + QVERIFY(sb.allocateTo(6)); + } + + void testSwitchLaterPromotesOnCue() { + SpareBackup sb; + sb.setSpareChannel(40); + sb.allocateTo(5); + sb.switchLater(); + QCOMPARE(sb.state(), SpareBackup::State::Armed); + sb.onCueFired(); + QCOMPARE(sb.state(), SpareBackup::State::Active); + } + + void testDroppingSpareClears() { + SpareBackup sb; + sb.setSpareChannel(40); + sb.allocateTo(5); + sb.switchNow(); + sb.setSpareChannel(-1); + QVERIFY(!sb.isAllocated()); + QCOMPARE(sb.state(), SpareBackup::State::Inactive); + } + + void testRoundTrip() { + SpareBackup sb; + sb.setSpareChannel(40); + sb.allocateTo(5); + sb.switchLater(); + + SpareBackup restored; + restored.loadFromJson(sb.toJson()); + QCOMPARE(restored.spareChannel(), 40); + QCOMPARE(restored.allocatedChannel(), 5); + QCOMPARE(restored.state(), SpareBackup::State::Armed); + } +}; + +QTEST_MAIN(TestSpareBackup) +#include "test_spare_backup.moc" From 46e8d3654d1442b955220b4c249f4cc0de0acb85 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 01:15:05 -0400 Subject: [PATCH 20/92] feat: spare-backup switch dialog and mixer apply --- CMakeLists.txt | 2 + src/app/Application.cpp | 15 ++++ src/core/PlaybackEngine.cpp | 16 ++++ src/core/PlaybackEngine.h | 4 + src/ui/AllocateSpareDialog.cpp | 158 +++++++++++++++++++++++++++++++++ src/ui/AllocateSpareDialog.h | 43 +++++++++ src/ui/MainWindow.cpp | 12 +++ src/ui/MainWindow.h | 2 + 8 files changed, 252 insertions(+) create mode 100644 src/ui/AllocateSpareDialog.cpp create mode 100644 src/ui/AllocateSpareDialog.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 1e7af24..d124b57 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -129,6 +129,7 @@ set(SOURCES src/ui/TimecodePanel.cpp src/ui/CueZeroDialog.cpp src/ui/ChannelUtilisationDialog.cpp + src/ui/AllocateSpareDialog.cpp src/ui/PopOutWindow.cpp src/ui/BubbleButton.cpp src/ui/BubbleBar.cpp @@ -234,6 +235,7 @@ set(HEADERS src/ui/TimecodePanel.h src/ui/CueZeroDialog.h src/ui/ChannelUtilisationDialog.h + src/ui/AllocateSpareDialog.h src/ui/PopOutWindow.h src/ui/BubbleButton.h src/ui/BubbleBar.h diff --git a/src/app/Application.cpp b/src/app/Application.cpp index 3c3a59a..d0b5b12 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -20,6 +20,7 @@ #include "core/ScribbleController.h" #include "core/ShortcutManager.h" #include "core/Show.h" +#include "core/SpareBackup.h" #include "io/AutosaveManager.h" #include "io/CrashRecovery.h" #include "midi/MidiInputManager.h" @@ -251,6 +252,20 @@ void Application::initialize() { settings.value("clipHoldMs", m_channelMonitor->clipHoldMs()).toInt()); settings.endGroup(); } + + // spare-backup mic switch: a queued (Later) switch promotes on the next cue + // fire; when the switch goes live, apply the covered actor's backup voice to + // the spare channel. + connect(m_playbackEngine, &PlaybackEngine::cueExecuted, m_show->spareBackup(), + &SpareBackup::onCueFired); + connect(m_show->spareBackup(), &SpareBackup::stateChanged, this, + [this](SpareBackup::State state) { + if (state != SpareBackup::State::Active) + return; + SpareBackup* spare = m_show->spareBackup(); + m_playbackEngine->applyBackupSwitch(spare->allocatedChannel(), + spare->spareChannel()); + }); } void Application::setupMixerConnection(const QString& type, const QString& host, int port) { diff --git a/src/core/PlaybackEngine.cpp b/src/core/PlaybackEngine.cpp index fff8f27..f0aea54 100644 --- a/src/core/PlaybackEngine.cpp +++ b/src/core/PlaybackEngine.cpp @@ -464,6 +464,22 @@ void PlaybackEngine::applyVoice(int channel, const VoiceData& voice) { voice.dynRelease.value_or(100.0), voice.dynGain.value_or(0.0)); } +void PlaybackEngine::applyBackupSwitch(int coveredChannel, int spareChannel) { + if (!m_mixer || !m_actorLibrary || spareChannel < 0) + return; + const Actor* actor = m_actorLibrary->actorForChannel(coveredChannel); + if (!actor) + return; + // apply the first stored backup voice this actor carries to the spare channel + for (const QString& slot : actor->profileSlots()) { + const ActorProfile profile = actor->profile(slot); + if (!profile.backup().isEmpty()) { + applyVoice(spareChannel, profile.backup()); + return; + } + } +} + void PlaybackEngine::verifyCue(int index, const Cue& cue) { if (!m_verifyCues || !m_mixer || !m_mixer->isConnected()) return; diff --git a/src/core/PlaybackEngine.h b/src/core/PlaybackEngine.h index 75a5a95..a40ec46 100644 --- a/src/core/PlaybackEngine.h +++ b/src/core/PlaybackEngine.h @@ -40,6 +40,10 @@ class PlaybackEngine : public QObject { // ganged input-channel pairs (from the show); a level applied to one channel // is mirrored to its partner on fire. void setChannelGangs(const QList>& gangs); + + // apply the backup voice of the actor covering `coveredChannel` to the spare + // channel (spare-backup mic switch). No-op without a mixer/actor/backup voice. + void applyBackupSwitch(int coveredChannel, int spareChannel); [[nodiscard]] QList> channelGangs() const { return m_channelGangs; } // check / soundcheck (rehearsal) mode: while enabled, GO re-fires the current diff --git a/src/ui/AllocateSpareDialog.cpp b/src/ui/AllocateSpareDialog.cpp new file mode 100644 index 0000000..4078f45 --- /dev/null +++ b/src/ui/AllocateSpareDialog.cpp @@ -0,0 +1,158 @@ +#include "AllocateSpareDialog.h" +#include "app/Application.h" +#include "core/Show.h" +#include "core/SpareBackup.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace OpenMix { + +namespace { +// spinbox value 0 means "none"; the model uses -1 +int toChannel(int spinValue) { + return spinValue <= 0 ? -1 : spinValue; +} +} // namespace + +AllocateSpareDialog::AllocateSpareDialog(Application* app, QWidget* parent) + : QDialog(parent), m_app(app), m_spare(app && app->show() ? app->show()->spareBackup() + : nullptr) { + setWindowTitle(tr("Allocate Spare Backup")); + setModal(true); + + QVBoxLayout* mainLayout = new QVBoxLayout(this); + + QFormLayout* form = new QFormLayout(); + + QHBoxLayout* spareRow = new QHBoxLayout(); + m_spareChannelSpin = new QSpinBox(this); + m_spareChannelSpin->setRange(0, 128); + m_spareChannelSpin->setSpecialValueText(tr("None")); + m_spareChannelSpin->setToolTip(tr("Console channel reserved as the spare mic")); + spareRow->addWidget(m_spareChannelSpin); + QPushButton* setSpareButton = new QPushButton(tr("Set"), this); + connect(setSpareButton, &QPushButton::clicked, this, &AllocateSpareDialog::setSpareChannel); + spareRow->addWidget(setSpareButton); + form->addRow(tr("Spare channel:"), spareRow); + + QHBoxLayout* allocRow = new QHBoxLayout(); + m_allocateSpin = new QSpinBox(this); + m_allocateSpin->setRange(0, 128); + m_allocateSpin->setSpecialValueText(tr("None")); + m_allocateSpin->setToolTip(tr("Channel the spare should cover")); + allocRow->addWidget(m_allocateSpin); + m_allocateButton = new QPushButton(tr("Allocate"), this); + connect(m_allocateButton, &QPushButton::clicked, this, &AllocateSpareDialog::allocate); + allocRow->addWidget(m_allocateButton); + form->addRow(tr("Cover channel:"), allocRow); + + mainLayout->addLayout(form); + + m_statusLabel = new QLabel(this); + m_statusLabel->setWordWrap(true); + mainLayout->addWidget(m_statusLabel); + + QHBoxLayout* switchRow = new QHBoxLayout(); + m_switchNowButton = new QPushButton(tr("Switch &Now"), this); + connect(m_switchNowButton, &QPushButton::clicked, this, &AllocateSpareDialog::switchNow); + switchRow->addWidget(m_switchNowButton); + m_switchLaterButton = new QPushButton(tr("Switch &Later"), this); + connect(m_switchLaterButton, &QPushButton::clicked, this, &AllocateSpareDialog::switchLater); + switchRow->addWidget(m_switchLaterButton); + m_removeButton = new QPushButton(tr("&Remove Allocation"), this); + connect(m_removeButton, &QPushButton::clicked, this, &AllocateSpareDialog::removeAllocation); + switchRow->addWidget(m_removeButton); + mainLayout->addLayout(switchRow); + + QDialogButtonBox* buttons = new QDialogButtonBox(QDialogButtonBox::Close, this); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + mainLayout->addWidget(buttons); + + if (m_spare) { + m_spareChannelSpin->setValue(m_spare->spareChannel() < 0 ? 0 : m_spare->spareChannel()); + if (m_spare->isAllocated()) + m_allocateSpin->setValue(m_spare->allocatedChannel()); + } + updateStatus(); +} + +void AllocateSpareDialog::setSpareChannel() { + if (!m_spare) + return; + m_spare->setSpareChannel(toChannel(m_spareChannelSpin->value())); + updateStatus(); +} + +void AllocateSpareDialog::allocate() { + if (!m_spare) + return; + m_spare->allocateTo(toChannel(m_allocateSpin->value())); + updateStatus(); +} + +void AllocateSpareDialog::switchNow() { + if (m_spare) + m_spare->switchNow(); + updateStatus(); +} + +void AllocateSpareDialog::switchLater() { + if (m_spare) + m_spare->switchLater(); + updateStatus(); +} + +void AllocateSpareDialog::removeAllocation() { + if (m_spare) + m_spare->removeAllocation(); + updateStatus(); +} + +void AllocateSpareDialog::updateStatus() { + if (!m_spare) { + m_statusLabel->setText(tr("No show loaded.")); + return; + } + + const bool hasSpare = m_spare->spareChannel() >= 0; + const bool allocated = m_spare->isAllocated(); + const bool active = m_spare->isActive(); + + QString text; + if (!hasSpare) { + text = tr("No spare channel configured."); + } else if (!allocated) { + text = tr("Spare channel %1 is idle.").arg(m_spare->spareChannel()); + } else { + QString stateText; + switch (m_spare->state()) { + case SpareBackup::State::Inactive: + stateText = tr("allocated (primary live)"); + break; + case SpareBackup::State::Armed: + stateText = tr("switch armed for next cue"); + break; + case SpareBackup::State::Active: + stateText = tr("backup live"); + break; + } + text = tr("Spare channel %1 covers channel %2 — %3.") + .arg(m_spare->spareChannel()) + .arg(m_spare->allocatedChannel()) + .arg(stateText); + } + m_statusLabel->setText(text); + + m_allocateButton->setEnabled(hasSpare && !active); + m_switchNowButton->setEnabled(allocated && !active); + m_switchLaterButton->setEnabled(allocated && !active); + m_removeButton->setEnabled(allocated); +} + +} // namespace OpenMix diff --git a/src/ui/AllocateSpareDialog.h b/src/ui/AllocateSpareDialog.h new file mode 100644 index 0000000..49d0dad --- /dev/null +++ b/src/ui/AllocateSpareDialog.h @@ -0,0 +1,43 @@ +#pragma once + +#include + +class QLabel; +class QPushButton; +class QSpinBox; + +namespace OpenMix { + +class Application; +class SpareBackup; + +// Allocate the reserved spare-backup channel to cover an input, then switch to +// the backup now or on the next cue. Drives the show's SpareBackup model. +class AllocateSpareDialog : public QDialog { + Q_OBJECT + + public: + explicit AllocateSpareDialog(Application* app, QWidget* parent = nullptr); + + private slots: + void setSpareChannel(); + void allocate(); + void switchNow(); + void switchLater(); + void removeAllocation(); + void updateStatus(); + + private: + Application* m_app; + SpareBackup* m_spare; + + QSpinBox* m_spareChannelSpin; + QSpinBox* m_allocateSpin; + QLabel* m_statusLabel; + QPushButton* m_allocateButton; + QPushButton* m_switchNowButton; + QPushButton* m_switchLaterButton; + QPushButton* m_removeButton; +}; + +} // namespace OpenMix diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index f5c7867..78a9e28 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -4,6 +4,7 @@ #include "BubbleButton.h" #include "ConnectionPanel.h" #include "CueEditor.h" +#include "AllocateSpareDialog.h" #include "ChannelUtilisationDialog.h" #include "CueListView.h" #include "CueTableModel.h" @@ -234,6 +235,10 @@ void MainWindow::createActions() { tr("Panic then restore to previous state (Ctrl+Shift+Escape)")); connect(m_panicRestoreAction, &QAction::triggered, this, &MainWindow::panicRestore); + m_spareBackupAction = new QAction(tr("Allocate &Spare..."), this); + m_spareBackupAction->setToolTip(tr("Allocate the spare-backup channel and switch to it")); + connect(m_spareBackupAction, &QAction::triggered, this, &MainWindow::showAllocateSpareDialog); + m_showDCAMappingAction = new QAction(Icons::sliders(), tr("&DCA Mapping"), this); m_showDCAMappingAction->setCheckable(true); m_showDCAMappingAction->setChecked(false); @@ -430,6 +435,8 @@ void MainWindow::createMenus() { m_playbackMenu->addSeparator(); m_playbackMenu->addAction(m_panicAction); m_playbackMenu->addAction(m_panicRestoreAction); + m_playbackMenu->addSeparator(); + m_playbackMenu->addAction(m_spareBackupAction); m_viewMenu = menuBar()->addMenu(tr("&View")); m_viewMenu->addAction(m_showDCAMappingAction); @@ -921,6 +928,11 @@ void MainWindow::go() { m_app->playbackEngine()->go(); } void MainWindow::stopPlayback() { m_app->playbackEngine()->stop(); } +void MainWindow::showAllocateSpareDialog() { + AllocateSpareDialog dialog(m_app, this); + dialog.exec(); +} + void MainWindow::openConnectionPanel() { if (!m_connectionPopOut->isVisible()) { m_connectionPopOut->showAndRestore(); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 590d865..fda93b3 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -58,6 +58,7 @@ class MainWindow : public QMainWindow { // playback actions void go(); void stopPlayback(); + void showAllocateSpareDialog(); // view actions - pop-out windows void toggleConnectionPanel(); @@ -189,6 +190,7 @@ class MainWindow : public QMainWindow { // safety actions QAction* m_panicAction; QAction* m_panicRestoreAction; + QAction* m_spareBackupAction; // view actions (for menu checkable items) QAction* m_showConnectionAction; From 9f07b5c4d57d67e18c82a6b8c3a2563d4fc9eb76 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 01:19:10 -0400 Subject: [PATCH 21/92] feat: export cue notes to text file --- src/ui/MainWindow.cpp | 35 +++++++++++++++++++++++++++++++++++ src/ui/MainWindow.h | 2 ++ 2 files changed, 37 insertions(+) diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 78a9e28..8757532 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -126,6 +126,10 @@ void MainWindow::createActions() { m_saveAsAction->setToolTip(tr("Save the show with a new name (Ctrl+Shift+S)")); connect(m_saveAsAction, &QAction::triggered, this, &MainWindow::saveShowAs); + m_exportNotesAction = new QAction(tr("Export &Notes..."), this); + m_exportNotesAction->setToolTip(tr("Export cue notes to a text file")); + connect(m_exportNotesAction, &QAction::triggered, this, &MainWindow::exportNotes); + m_exitAction = new QAction(Icons::appExit(), tr("E&xit"), this); m_exitAction->setShortcut(QKeySequence::Quit); m_exitAction->setToolTip(tr("Exit the application")); @@ -403,6 +407,8 @@ void MainWindow::createMenus() { m_fileMenu->addAction(m_saveAction); m_fileMenu->addAction(m_saveAsAction); m_fileMenu->addSeparator(); + m_fileMenu->addAction(m_exportNotesAction); + m_fileMenu->addSeparator(); m_fileMenu->addAction(m_exitAction); m_editMenu = menuBar()->addMenu(tr("&Edit")); @@ -1215,6 +1221,35 @@ void MainWindow::showChannelUtilisationDialog() { dialog.exec(); } +void MainWindow::exportNotes() { + QString filePath = QFileDialog::getSaveFileName(this, tr("Export Notes"), QString(), + tr("Text Files (*.txt)")); + if (filePath.isEmpty()) + return; + if (!filePath.endsWith(".txt", Qt::CaseInsensitive)) + filePath += ".txt"; + + QFile file(filePath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + QMessageBox::warning(this, tr("Error"), tr("Could not write %1").arg(filePath)); + return; + } + + QTextStream out(&file); + out << m_app->show()->name() << " - Cue Notes\n\n"; + const CueList* list = m_app->show()->cueList(); + for (int i = 0; i < list->count(); ++i) { + const Cue& cue = list->at(i); + if (cue.notes().isEmpty()) + continue; + out << QString::number(cue.number(), 'f', 2); + if (!cue.name().isEmpty()) + out << " - " << cue.name(); + out << '\n' << cue.notes() << "\n\n"; + } + file.close(); +} + void MainWindow::exportCuesToCsv() { QString filePath = QFileDialog::getSaveFileName(this, tr("Export to CSV"), QString(), tr("CSV Files (*.csv)")); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index fda93b3..b13ec89 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -47,6 +47,7 @@ class MainWindow : public QMainWindow { void importTmixShow(); void saveShow(); void saveShowAs(); + void exportNotes(); // edit menu actions void addCue(); @@ -161,6 +162,7 @@ class MainWindow : public QMainWindow { QAction* m_importTmixAction; QAction* m_saveAction; QAction* m_saveAsAction; + QAction* m_exportNotesAction; QAction* m_exitAction; // edit actions From f3a76e696423a693b83d56cc963baf6642f7d338 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 01:21:08 -0400 Subject: [PATCH 22/92] feat: active cue info panel --- CMakeLists.txt | 2 + src/ui/ActiveCueInfoPanel.cpp | 92 +++++++++++++++++++++++++++++++++++ src/ui/ActiveCueInfoPanel.h | 36 ++++++++++++++ src/ui/MainWindow.cpp | 31 ++++++++++++ src/ui/MainWindow.h | 5 ++ 5 files changed, 166 insertions(+) create mode 100644 src/ui/ActiveCueInfoPanel.cpp create mode 100644 src/ui/ActiveCueInfoPanel.h diff --git a/CMakeLists.txt b/CMakeLists.txt index d124b57..5fb7d68 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -130,6 +130,7 @@ set(SOURCES src/ui/CueZeroDialog.cpp src/ui/ChannelUtilisationDialog.cpp src/ui/AllocateSpareDialog.cpp + src/ui/ActiveCueInfoPanel.cpp src/ui/PopOutWindow.cpp src/ui/BubbleButton.cpp src/ui/BubbleBar.cpp @@ -236,6 +237,7 @@ set(HEADERS src/ui/CueZeroDialog.h src/ui/ChannelUtilisationDialog.h src/ui/AllocateSpareDialog.h + src/ui/ActiveCueInfoPanel.h src/ui/PopOutWindow.h src/ui/BubbleButton.h src/ui/BubbleBar.h diff --git a/src/ui/ActiveCueInfoPanel.cpp b/src/ui/ActiveCueInfoPanel.cpp new file mode 100644 index 0000000..3fc94fc --- /dev/null +++ b/src/ui/ActiveCueInfoPanel.cpp @@ -0,0 +1,92 @@ +#include "ActiveCueInfoPanel.h" +#include "app/Application.h" +#include "core/Cue.h" +#include "core/CueList.h" +#include "core/PlaybackEngine.h" +#include "core/Show.h" + +#include +#include +#include +#include +#include + +namespace OpenMix { + +ActiveCueInfoPanel::ActiveCueInfoPanel(Application* app, QWidget* parent) + : QWidget(parent), m_app(app) { + setupUi(); + + if (m_app && m_app->playbackEngine()) + connect(m_app->playbackEngine(), &PlaybackEngine::currentCueChanged, this, + &ActiveCueInfoPanel::showCue); + refresh(); +} + +void ActiveCueInfoPanel::setupUi() { + QVBoxLayout* mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(8, 8, 8, 8); + + QFormLayout* form = new QFormLayout(); + m_numberLabel = new QLabel(this); + m_nameLabel = new QLabel(this); + m_nameLabel->setWordWrap(true); + m_typeLabel = new QLabel(this); + m_fadeLabel = new QLabel(this); + form->addRow(tr("Number:"), m_numberLabel); + form->addRow(tr("Name:"), m_nameLabel); + form->addRow(tr("Type:"), m_typeLabel); + form->addRow(tr("Fade:"), m_fadeLabel); + mainLayout->addLayout(form); + + m_detailsEdit = new QTextEdit(this); + m_detailsEdit->setReadOnly(true); + mainLayout->addWidget(m_detailsEdit, 1); +} + +void ActiveCueInfoPanel::refresh() { + showCue(m_app && m_app->playbackEngine() ? m_app->playbackEngine()->currentCueIndex() : -1); +} + +void ActiveCueInfoPanel::showCue(int index) { + const CueList* list = m_app && m_app->show() ? m_app->show()->cueList() : nullptr; + if (!list || index < 0 || index >= list->count()) { + m_numberLabel->setText(tr("--")); + m_nameLabel->clear(); + m_typeLabel->clear(); + m_fadeLabel->clear(); + m_detailsEdit->clear(); + return; + } + + const Cue& cue = list->at(index); + m_numberLabel->setText(QString::number(cue.number(), 'f', 2)); + m_nameLabel->setText(cue.name().isEmpty() ? tr("(unnamed)") : cue.name()); + m_typeLabel->setText(cueTypeToString(cue.type())); + m_fadeLabel->setText(cue.fadeTime() > 0.0 ? tr("%1 s").arg(cue.fadeTime(), 0, 'f', 1) + : tr("instant")); + + QStringList lines; + if (!cue.targetsAllDCAs()) + lines << tr("Targets %n DCA(s)", "", cue.targetedDCAs().size()); + if (!cue.dcaOverrides().isEmpty()) + lines << tr("%n DCA override(s)", "", cue.dcaOverrides().size()); + if (!cue.channelProfiles().isEmpty()) + lines << tr("%n channel profile(s)", "", cue.channelProfiles().size()); + if (!cue.channelLevels().isEmpty()) + lines << tr("%n channel level(s)", "", cue.channelLevels().size()); + if (!cue.channelPositions().isEmpty()) + lines << tr("%n channel position(s)", "", cue.channelPositions().size()); + if (!cue.fxMutes().isEmpty()) + lines << tr("%n FX mute(s)", "", cue.fxMutes().size()); + if (!cue.snippets().isEmpty()) + lines << tr("%n snippet(s)", "", cue.snippets().size()); + if (cue.skip()) + lines << tr("Skipped in standby advance"); + if (!cue.notes().isEmpty()) + lines << QString() << cue.notes(); + + m_detailsEdit->setPlainText(lines.join('\n')); +} + +} // namespace OpenMix diff --git a/src/ui/ActiveCueInfoPanel.h b/src/ui/ActiveCueInfoPanel.h new file mode 100644 index 0000000..a3ec106 --- /dev/null +++ b/src/ui/ActiveCueInfoPanel.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +class QLabel; +class QTextEdit; + +namespace OpenMix { + +class Application; + +// Live read-only summary of the current (last-fired) cue: number, name, type, +// fade, and a breakdown of what it changes. Updates as playback advances. +class ActiveCueInfoPanel : public QWidget { + Q_OBJECT + + public: + explicit ActiveCueInfoPanel(Application* app, QWidget* parent = nullptr); + + public slots: + void refresh(); + + private: + void setupUi(); + void showCue(int index); + + Application* m_app; + + QLabel* m_numberLabel; + QLabel* m_nameLabel; + QLabel* m_typeLabel; + QLabel* m_fadeLabel; + QTextEdit* m_detailsEdit; +}; + +} // namespace OpenMix diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 8757532..ffff8bb 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -4,6 +4,7 @@ #include "BubbleButton.h" #include "ConnectionPanel.h" #include "CueEditor.h" +#include "ActiveCueInfoPanel.h" #include "AllocateSpareDialog.h" #include "ChannelUtilisationDialog.h" #include "CueListView.h" @@ -293,6 +294,13 @@ void MainWindow::createActions() { m_showTimecodeAction->setToolTip(tr("Show/hide timecode triggers panel (F12)")); connect(m_showTimecodeAction, &QAction::triggered, this, &MainWindow::toggleTimecodePanel); + m_showActiveCueInfoAction = new QAction(tr("Active Cue &Info"), this); + m_showActiveCueInfoAction->setCheckable(true); + m_showActiveCueInfoAction->setChecked(false); + m_showActiveCueInfoAction->setToolTip(tr("Show/hide the active cue info panel")); + connect(m_showActiveCueInfoAction, &QAction::triggered, this, + &MainWindow::toggleActiveCueInfoPanel); + m_cueZeroAction = new QAction(tr("Cue &Zero..."), this); m_cueZeroAction->setToolTip(tr("Edit the base/reset state recalled before the first cue")); connect(m_cueZeroAction, &QAction::triggered, this, &MainWindow::showCueZeroDialog); @@ -452,6 +460,7 @@ void MainWindow::createMenus() { m_viewMenu->addAction(m_showEnsembleAction); m_viewMenu->addAction(m_showPositionAction); m_viewMenu->addAction(m_showTimecodeAction); + m_viewMenu->addAction(m_showActiveCueInfoAction); m_viewMenu->addSeparator(); // cue-table row size @@ -613,6 +622,16 @@ void MainWindow::createPopOutWindows() { m_showTimecodeAction->setChecked(visible); m_bubbleBar->setButtonActive("timecode", visible); }); + + m_activeCueInfoPanel = new ActiveCueInfoPanel(m_app, nullptr); + m_activeCueInfoPopOut = new PopOutWindow("activeCueInfo", tr("Active Cue Info"), this); + m_activeCueInfoPopOut->setContentWidget(m_activeCueInfoPanel); + m_activeCueInfoPopOut->setMinimumContentSize(320, 400); + + connect(m_activeCueInfoPopOut, &PopOutWindow::visibilityChanged, [this](bool visible) { + m_showActiveCueInfoAction->setChecked(visible); + m_bubbleBar->setButtonActive("activeCueInfo", visible); + }); } void MainWindow::createBubbleBar() { @@ -625,6 +644,7 @@ void MainWindow::createBubbleBar() { m_bubbleBar->addButton("ensembles", Icons::actor(), tr("Ensembles (F10)")); m_bubbleBar->addButton("positions", Icons::sliders(), tr("Positions (F11)")); m_bubbleBar->addButton("timecode", Icons::sliders(), tr("Timecode Triggers (F12)")); + m_bubbleBar->addButton("activeCueInfo", Icons::sliders(), tr("Active Cue Info")); connect(m_bubbleBar, &BubbleBar::buttonClicked, this, &MainWindow::onBubbleButtonClicked); @@ -647,6 +667,8 @@ void MainWindow::onBubbleButtonClicked(const QString& id, [[maybe_unused]] bool togglePositionPanel(); } else if (id == "timecode") { toggleTimecodePanel(); + } else if (id == "activeCueInfo") { + toggleActiveCueInfoPanel(); } } @@ -1006,6 +1028,15 @@ void MainWindow::toggleTimecodePanel() { } } +void MainWindow::toggleActiveCueInfoPanel() { + if (m_activeCueInfoPopOut->isVisible()) { + m_activeCueInfoPopOut->hide(); + } else { + m_activeCueInfoPopOut->showAndRestore(); + m_activeCueInfoPanel->refresh(); + } +} + void MainWindow::showCueZeroDialog() { CueZeroDialog dialog(m_app, this); dialog.exec(); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index b13ec89..baa0082 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -21,6 +21,7 @@ class ActorSetupPanel; class EnsemblePanel; class PositionPanel; class TimecodePanel; +class ActiveCueInfoPanel; class PopOutWindow; class BubbleBar; class PlaybackGuard; @@ -69,6 +70,7 @@ class MainWindow : public QMainWindow { void toggleEnsemblePanel(); void togglePositionPanel(); void toggleTimecodePanel(); + void toggleActiveCueInfoPanel(); void showCueZeroDialog(); // update UI state @@ -130,6 +132,7 @@ class MainWindow : public QMainWindow { EnsemblePanel* m_ensemblePanel; PositionPanel* m_positionPanel; TimecodePanel* m_timecodePanel; + ActiveCueInfoPanel* m_activeCueInfoPanel; // pop-out windows PopOutWindow* m_connectionPopOut; @@ -139,6 +142,7 @@ class MainWindow : public QMainWindow { PopOutWindow* m_ensemblePopOut; PopOutWindow* m_positionPopOut; PopOutWindow* m_timecodePopOut; + PopOutWindow* m_activeCueInfoPopOut; // bubble bar BubbleBar* m_bubbleBar; @@ -202,6 +206,7 @@ class MainWindow : public QMainWindow { QAction* m_showEnsembleAction; QAction* m_showPositionAction; QAction* m_showTimecodeAction; + QAction* m_showActiveCueInfoAction; QAction* m_cueZeroAction; QAction* m_editHistoryAction; QAction* m_exportCsvAction; From e6e491a2e1b8fcbf5e16dab49d76b496084ce466 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 01:22:50 -0400 Subject: [PATCH 23/92] feat: clone level offsets between cues --- src/ui/CueListView.cpp | 18 ++++++++++++++++++ src/ui/CueListView.h | 1 + src/ui/MainWindow.cpp | 7 +++++++ src/ui/MainWindow.h | 1 + 4 files changed, 27 insertions(+) diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index b7a77eb..f8c80ac 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -377,6 +377,24 @@ void CueListView::fillDown() { selectSourceRow(idx + 1); } +void CueListView::cloneOffsets() { + int idx = selectedCueIndex(); + CueList* cueList = m_app->show()->cueList(); + if (idx < 0 || idx + 1 >= cueList->count()) + return; + const QMap levels = cueList->at(idx).channelLevels(); + if (levels.isEmpty()) + return; + + Cue oldCue = cueList->at(idx + 1); + Cue newCue = oldCue; + for (auto it = levels.begin(); it != levels.end(); ++it) + newCue.setChannelLevel(it.key(), it.value()); + m_app->undoStack()->push(new EditCueCommand(cueList, idx + 1, oldCue, newCue)); + cueList->updateCue(idx + 1, newCue); + selectSourceRow(idx + 1); +} + void CueListView::jumpToSelectedCue() { int idx = selectedCueIndex(); if (idx < 0) diff --git a/src/ui/CueListView.h b/src/ui/CueListView.h index 7c24dec..56d1c6f 100644 --- a/src/ui/CueListView.h +++ b/src/ui/CueListView.h @@ -45,6 +45,7 @@ class CueListView : public QWidget { void pasteCueMerge(); // merge clipboard content into the selected cue void pasteCueSwap(); // exchange content between clipboard and selected cue void fillDown(); // copy the selected cue's content into the next cue + void cloneOffsets(); // copy just the selected cue's level offsets to the next cue void jumpToSelectedCue(); // set the selected cue as standby without firing void setEditingLocked(bool locked); // make the cue table read-only void setRowHeight(int pixels); // cue-table row height diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index ffff8bb..2ba79a5 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -194,6 +194,11 @@ void MainWindow::createActions() { m_fillDownAction->setToolTip(tr("Copy the selected cue's content into the next cue")); connect(m_fillDownAction, &QAction::triggered, this, [this]() { m_cueListView->fillDown(); }); + m_cloneOffsetsAction = new QAction(tr("Cl&one Offsets"), this); + m_cloneOffsetsAction->setToolTip(tr("Copy the selected cue's level offsets to the next cue")); + connect(m_cloneOffsetsAction, &QAction::triggered, this, + [this]() { m_cueListView->cloneOffsets(); }); + m_jumpToSelectedAction = new QAction(tr("&Jump to Selected Cue"), this); m_jumpToSelectedAction->setToolTip(tr("Set the selected cue as standby without firing")); connect(m_jumpToSelectedAction, &QAction::triggered, this, @@ -433,6 +438,7 @@ void MainWindow::createMenus() { m_editMenu->addAction(m_pasteMergeAction); m_editMenu->addAction(m_pasteSwapAction); m_editMenu->addAction(m_fillDownAction); + m_editMenu->addAction(m_cloneOffsetsAction); m_editMenu->addSeparator(); m_editMenu->addAction(m_renumberAction); m_editMenu->addAction(m_jumpToSelectedAction); @@ -1068,6 +1074,7 @@ void MainWindow::updateActions() { m_pasteMergeAction->setEnabled(editable && hasSelection && m_cueListView->hasClipboardCue()); m_pasteSwapAction->setEnabled(editable && hasSelection && m_cueListView->hasClipboardCue()); m_fillDownAction->setEnabled(editable && hasSelection); + m_cloneOffsetsAction->setEnabled(editable && hasSelection); m_jumpToSelectedAction->setEnabled(hasSelection); m_jumpAction->setEnabled(hasCues); } diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index baa0082..bdf5160 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -182,6 +182,7 @@ class MainWindow : public QMainWindow { QAction* m_pasteMergeAction; QAction* m_pasteSwapAction; QAction* m_fillDownAction; + QAction* m_cloneOffsetsAction; QAction* m_jumpToSelectedAction; QAction* m_jumpAction; QAction* m_lockEditingAction; From 19c0fada2b5f1c75b8405e2f2805c55a5b8b645d Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 01:28:45 -0400 Subject: [PATCH 24/92] feat: FX unit registry and channel assignments --- CMakeLists.txt | 2 + src/core/FxLibrary.cpp | 137 ++++++++++++++++++++++++++++++++++ src/core/FxLibrary.h | 60 +++++++++++++++ src/core/Show.cpp | 15 +++- src/core/Show.h | 5 ++ tests/CMakeLists.txt | 20 +++++ tests/test_actor_profiles.cpp | 2 +- tests/test_ensembles.cpp | 2 +- tests/test_fx_library.cpp | 60 +++++++++++++++ 9 files changed, 299 insertions(+), 4 deletions(-) create mode 100644 src/core/FxLibrary.cpp create mode 100644 src/core/FxLibrary.h create mode 100644 tests/test_fx_library.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5fb7d68..76cfcf5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -85,6 +85,7 @@ set(SOURCES src/core/CueList.cpp src/core/Show.cpp src/core/SpareBackup.cpp + src/core/FxLibrary.cpp src/core/PlaybackEngine.cpp src/core/UndoCommands.cpp src/core/CueValidator.cpp @@ -190,6 +191,7 @@ set(HEADERS src/core/CueList.h src/core/Show.h src/core/SpareBackup.h + src/core/FxLibrary.h src/core/PlaybackEngine.h src/core/UndoCommands.h src/core/CueValidator.h diff --git a/src/core/FxLibrary.cpp b/src/core/FxLibrary.cpp new file mode 100644 index 0000000..d6e3031 --- /dev/null +++ b/src/core/FxLibrary.cpp @@ -0,0 +1,137 @@ +#include "FxLibrary.h" +#include + +namespace OpenMix { + +QJsonObject FxUnit::toJson() const { + QJsonObject json; + json["index"] = index; + json["name"] = name; + return json; +} + +FxUnit FxUnit::fromJson(const QJsonObject& json) { + FxUnit unit; + unit.index = json.value("index").toInt(); + unit.name = json.value("name").toString(); + return unit; +} + +FxLibrary::FxLibrary(QObject* parent) : QObject(parent) {} + +void FxLibrary::setUnit(int index, const QString& name) { + if (m_units.value(index) == name && m_units.contains(index)) + return; + m_units[index] = name; + emit changed(); +} + +void FxLibrary::removeUnit(int index) { + bool touched = m_units.remove(index) > 0; + // drop the unit from any channel assignment + for (auto it = m_assignments.begin(); it != m_assignments.end(); ++it) { + if (it.value().removeAll(index) > 0) + touched = true; + } + if (m_defaultFx == index) { + m_defaultFx = -1; + touched = true; + } + if (touched) + emit changed(); +} + +QString FxLibrary::unitName(int index) const { + return m_units.value(index); +} + +QList FxLibrary::units() const { + QList list; + for (auto it = m_units.begin(); it != m_units.end(); ++it) + list.append({it.key(), it.value()}); + // QMap iterates keys ascending, so the list is already sorted by index + return list; +} + +void FxLibrary::setChannelAssignment(int channel, const QList& fxUnits) { + if (fxUnits.isEmpty()) { + clearChannelAssignment(channel); + return; + } + m_assignments[channel] = fxUnits; + emit changed(); +} + +QList FxLibrary::channelAssignment(int channel) const { + return m_assignments.value(channel); +} + +void FxLibrary::clearChannelAssignment(int channel) { + if (m_assignments.remove(channel) > 0) + emit changed(); +} + +void FxLibrary::setDefaultFx(int index) { + if (m_defaultFx == index) + return; + m_defaultFx = index; + emit changed(); +} + +bool FxLibrary::isEmpty() const { + return m_units.isEmpty() && m_assignments.isEmpty() && m_defaultFx < 0; +} + +void FxLibrary::clear() { + if (isEmpty()) + return; + m_units.clear(); + m_assignments.clear(); + m_defaultFx = -1; + emit changed(); +} + +QJsonObject FxLibrary::toJson() const { + QJsonObject json; + + QJsonArray unitsArray; + for (const FxUnit& unit : units()) + unitsArray.append(unit.toJson()); + json["units"] = unitsArray; + + QJsonObject assignsObj; + for (auto it = m_assignments.begin(); it != m_assignments.end(); ++it) { + QJsonArray units; + for (int fx : it.value()) + units.append(fx); + assignsObj[QString::number(it.key())] = units; + } + json["assignments"] = assignsObj; + json["defaultFx"] = m_defaultFx; + return json; +} + +void FxLibrary::loadFromJson(const QJsonObject& json) { + m_units.clear(); + m_assignments.clear(); + + const QJsonArray unitsArray = json.value("units").toArray(); + for (const QJsonValue& val : unitsArray) { + const FxUnit unit = FxUnit::fromJson(val.toObject()); + m_units[unit.index] = unit.name; + } + + const QJsonObject assignsObj = json.value("assignments").toObject(); + for (auto it = assignsObj.begin(); it != assignsObj.end(); ++it) { + QList units; + for (const QJsonValue& val : it.value().toArray()) + units.append(val.toInt()); + if (!units.isEmpty()) + m_assignments[it.key().toInt()] = units; + } + + m_defaultFx = json.value("defaultFx").toInt(-1); + emit changed(); +} + +} // namespace OpenMix diff --git a/src/core/FxLibrary.h b/src/core/FxLibrary.h new file mode 100644 index 0000000..8e6e097 --- /dev/null +++ b/src/core/FxLibrary.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace OpenMix { + +// A named FX unit (reverb/delay/etc.) identified by its console index. +struct FxUnit { + int index = 0; + QString name; + + QJsonObject toJson() const; + [[nodiscard]] static FxUnit fromJson(const QJsonObject& json); +}; + +// Show-owned FX setup: the named FX units, which channels send to which units, +// and a default unit. Cues carry per-unit mute state separately (Cue::fxMutes). +class FxLibrary : public QObject { + Q_OBJECT + + public: + explicit FxLibrary(QObject* parent = nullptr); + + // named units (index -> name) + void setUnit(int index, const QString& name); // add or rename + void removeUnit(int index); + [[nodiscard]] QString unitName(int index) const; + [[nodiscard]] bool hasUnit(int index) const { return m_units.contains(index); } + [[nodiscard]] QList units() const; // sorted by index + + // channel -> FX units it sends to + void setChannelAssignment(int channel, const QList& fxUnits); + [[nodiscard]] QList channelAssignment(int channel) const; + [[nodiscard]] QMap> assignments() const { return m_assignments; } + void clearChannelAssignment(int channel); + + // default FX unit applied to newly assigned channels (-1 = none) + [[nodiscard]] int defaultFx() const noexcept { return m_defaultFx; } + void setDefaultFx(int index); + + [[nodiscard]] bool isEmpty() const; + void clear(); + + QJsonObject toJson() const; + void loadFromJson(const QJsonObject& json); + + signals: + void changed(); + + private: + QMap m_units; // FX unit index -> name + QMap> m_assignments; // channel -> [FX unit index, ...] + int m_defaultFx = -1; +}; + +} // namespace OpenMix diff --git a/src/core/Show.cpp b/src/core/Show.cpp index fe02171..69a9236 100644 --- a/src/core/Show.cpp +++ b/src/core/Show.cpp @@ -23,8 +23,10 @@ MixerConfig MixerConfig::fromJson(const QJsonObject& json) { Show::Show(QObject* parent) : QObject(parent), m_cueList(this), m_dcaMapping(this), m_actorProfileLibrary(this), - m_positionLibrary(this), m_ensembleLibrary(this), m_cueZero(this), m_spareBackup(this) { + m_positionLibrary(this), m_ensembleLibrary(this), m_cueZero(this), m_spareBackup(this), + m_fxLibrary(this) { connect(&m_spareBackup, &SpareBackup::changed, this, &Show::checkModifiedState); + connect(&m_fxLibrary, &FxLibrary::changed, this, &Show::checkModifiedState); connectCueListSignals(); connectDcaMappingSignals(); connectActorLibrarySignals(); @@ -142,13 +144,14 @@ void Show::newShow() { m_ensembleLibrary.clear(); m_cueZero.clear(); m_spareBackup.setSpareChannel(-1); + m_fxLibrary.clear(); m_channelGangs.clear(); m_isDirty = false; } QJsonObject Show::toJson() const { QJsonObject json; - json["version"] = "1.5"; + json["version"] = "1.6"; json["name"] = m_name; json["author"] = m_author; json["designer"] = m_designer; @@ -166,6 +169,7 @@ QJsonObject Show::toJson() const { json["ensembles"] = m_ensembleLibrary.toJson(); json["cueZero"] = m_cueZero.toJson(); json["spareBackup"] = m_spareBackup.toJson(); + json["fx"] = m_fxLibrary.toJson(); QJsonArray gangArray; for (const auto& gang : m_channelGangs) { @@ -249,6 +253,13 @@ void Show::fromJson(const QJsonObject& json) { m_spareBackup.setSpareChannel(-1); } + // FX unit registry + assignments (added in show version 1.6) + if (json.contains("fx")) { + m_fxLibrary.loadFromJson(json["fx"].toObject()); + } else { + m_fxLibrary.clear(); + } + // ganged input-channel pairs m_channelGangs.clear(); if (json.contains("channelGangs")) { diff --git a/src/core/Show.h b/src/core/Show.h index f56f59d..36c0f1a 100644 --- a/src/core/Show.h +++ b/src/core/Show.h @@ -5,6 +5,7 @@ #include "CueZero.h" #include "DCAMapping.h" #include "Ensemble.h" +#include "FxLibrary.h" #include "Position.h" #include "SpareBackup.h" #include @@ -78,6 +79,9 @@ class Show : public QObject { [[nodiscard]] SpareBackup* spareBackup() { return &m_spareBackup; } [[nodiscard]] const SpareBackup* spareBackup() const { return &m_spareBackup; } + [[nodiscard]] FxLibrary* fxLibrary() { return &m_fxLibrary; } + [[nodiscard]] const FxLibrary* fxLibrary() const { return &m_fxLibrary; } + // recall the show's Cue Zero base state onto a connected mixer void applyCueZero(MixerProtocol* mixer) const { m_cueZero.apply(mixer); } @@ -145,6 +149,7 @@ class Show : public QObject { EnsembleLibrary m_ensembleLibrary; CueZero m_cueZero; SpareBackup m_spareBackup; + FxLibrary m_fxLibrary; bool m_isDirty = false; }; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 01f4da1..8b1e6f3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -31,6 +31,7 @@ add_executable(test_show test_show.cpp ${CMAKE_SOURCE_DIR}/src/core/Show.cpp ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp + ${CMAKE_SOURCE_DIR}/src/core/FxLibrary.cpp ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp @@ -60,6 +61,7 @@ add_executable(test_actor_profiles ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp ${CMAKE_SOURCE_DIR}/src/core/Show.cpp ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp + ${CMAKE_SOURCE_DIR}/src/core/FxLibrary.cpp ${CMAKE_SOURCE_DIR}/src/core/Ensemble.cpp ${CMAKE_SOURCE_DIR}/src/core/Position.cpp ${CMAKE_SOURCE_DIR}/src/core/CueZero.cpp @@ -289,6 +291,7 @@ add_executable(test_ensembles ${CMAKE_SOURCE_DIR}/src/core/Ensemble.cpp ${CMAKE_SOURCE_DIR}/src/core/Show.cpp ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp + ${CMAKE_SOURCE_DIR}/src/core/FxLibrary.cpp ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp @@ -327,6 +330,7 @@ add_executable(test_show_control ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp ${CMAKE_SOURCE_DIR}/src/core/Show.cpp ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp + ${CMAKE_SOURCE_DIR}/src/core/FxLibrary.cpp ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp ${CMAKE_SOURCE_DIR}/src/core/CueValidator.cpp ${CMAKE_SOURCE_DIR}/src/core/PlaybackGuard.cpp @@ -357,6 +361,7 @@ add_executable(test_tmix_import ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp ${CMAKE_SOURCE_DIR}/src/core/Show.cpp ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp + ${CMAKE_SOURCE_DIR}/src/core/FxLibrary.cpp ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp ${CMAKE_SOURCE_DIR}/src/core/ActorProfile.cpp ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp @@ -382,6 +387,7 @@ add_executable(test_channel_utilisation ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp ${CMAKE_SOURCE_DIR}/src/core/Show.cpp ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp + ${CMAKE_SOURCE_DIR}/src/core/FxLibrary.cpp ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp ${CMAKE_SOURCE_DIR}/src/core/ActorProfile.cpp ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp @@ -402,6 +408,7 @@ add_test(NAME ChannelUtilisationTest COMMAND test_channel_utilisation) add_executable(test_spare_backup test_spare_backup.cpp ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp + ${CMAKE_SOURCE_DIR}/src/core/FxLibrary.cpp ) target_link_libraries(test_spare_backup PRIVATE Qt6::Core @@ -411,3 +418,16 @@ target_include_directories(test_spare_backup PRIVATE ${CMAKE_SOURCE_DIR}/src ) add_test(NAME SpareBackupTest COMMAND test_spare_backup) + +add_executable(test_fx_library + test_fx_library.cpp + ${CMAKE_SOURCE_DIR}/src/core/FxLibrary.cpp +) +target_link_libraries(test_fx_library PRIVATE + Qt6::Core + Qt6::Test +) +target_include_directories(test_fx_library PRIVATE + ${CMAKE_SOURCE_DIR}/src +) +add_test(NAME FxLibraryTest COMMAND test_fx_library) diff --git a/tests/test_actor_profiles.cpp b/tests/test_actor_profiles.cpp index 464bedd..dc97ae6 100644 --- a/tests/test_actor_profiles.cpp +++ b/tests/test_actor_profiles.cpp @@ -174,7 +174,7 @@ class TestActorProfiles : public QObject { QVERIFY(spy.count() >= 1); const QJsonObject json = show.toJson(); - QCOMPARE(json["version"].toString(), QString("1.5")); + QCOMPARE(json["version"].toString(), QString("1.6")); Show loaded; loaded.fromJson(json); diff --git a/tests/test_ensembles.cpp b/tests/test_ensembles.cpp index fada37e..5edc196 100644 --- a/tests/test_ensembles.cpp +++ b/tests/test_ensembles.cpp @@ -113,7 +113,7 @@ class TestEnsembles : public QObject { QVERIFY(spy.count() >= 1); const QJsonObject json = show.toJson(); - QCOMPARE(json["version"].toString(), QString("1.5")); + QCOMPARE(json["version"].toString(), QString("1.6")); Show loaded; loaded.fromJson(json); diff --git a/tests/test_fx_library.cpp b/tests/test_fx_library.cpp new file mode 100644 index 0000000..91cebf2 --- /dev/null +++ b/tests/test_fx_library.cpp @@ -0,0 +1,60 @@ +#include "core/FxLibrary.h" +#include +#include + +using namespace OpenMix; + +class TestFxLibrary : public QObject { + Q_OBJECT + + private slots: + void testNamedUnits() { + FxLibrary fx; + fx.setUnit(1, "Reverb"); + fx.setUnit(2, "Delay"); + QCOMPARE(fx.unitName(1), QString("Reverb")); + QCOMPARE(fx.units().size(), 2); + QCOMPARE(fx.units().first().index, 1); // sorted by index + fx.setUnit(1, "Hall"); // rename, not add + QCOMPARE(fx.unitName(1), QString("Hall")); + QCOMPARE(fx.units().size(), 2); + } + + void testChannelAssignment() { + FxLibrary fx; + fx.setUnit(1, "Reverb"); + fx.setUnit(2, "Delay"); + fx.setChannelAssignment(5, {1, 2}); + QCOMPARE(fx.channelAssignment(5), QList({1, 2})); + fx.setChannelAssignment(5, {}); // empty clears + QVERIFY(fx.channelAssignment(5).isEmpty()); + } + + void testRemoveUnitDropsReferences() { + FxLibrary fx; + fx.setUnit(1, "Reverb"); + fx.setUnit(2, "Delay"); + fx.setChannelAssignment(5, {1, 2}); + fx.setDefaultFx(1); + fx.removeUnit(1); + QVERIFY(!fx.hasUnit(1)); + QCOMPARE(fx.channelAssignment(5), QList({2})); + QCOMPARE(fx.defaultFx(), -1); + } + + void testRoundTrip() { + FxLibrary fx; + fx.setUnit(1, "Reverb"); + fx.setChannelAssignment(5, {1}); + fx.setDefaultFx(1); + + FxLibrary restored; + restored.loadFromJson(fx.toJson()); + QCOMPARE(restored.unitName(1), QString("Reverb")); + QCOMPARE(restored.channelAssignment(5), QList({1})); + QCOMPARE(restored.defaultFx(), 1); + } +}; + +QTEST_MAIN(TestFxLibrary) +#include "test_fx_library.moc" From fd398704099e3a7421d701a76f1ddeac1e48ccac Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 01:30:33 -0400 Subject: [PATCH 25/92] feat: FX setup dialog --- CMakeLists.txt | 2 + src/ui/FxSetupDialog.cpp | 189 +++++++++++++++++++++++++++++++++++++++ src/ui/FxSetupDialog.h | 37 ++++++++ src/ui/MainWindow.cpp | 11 +++ src/ui/MainWindow.h | 2 + 5 files changed, 241 insertions(+) create mode 100644 src/ui/FxSetupDialog.cpp create mode 100644 src/ui/FxSetupDialog.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 76cfcf5..eb1f859 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -132,6 +132,7 @@ set(SOURCES src/ui/ChannelUtilisationDialog.cpp src/ui/AllocateSpareDialog.cpp src/ui/ActiveCueInfoPanel.cpp + src/ui/FxSetupDialog.cpp src/ui/PopOutWindow.cpp src/ui/BubbleButton.cpp src/ui/BubbleBar.cpp @@ -240,6 +241,7 @@ set(HEADERS src/ui/ChannelUtilisationDialog.h src/ui/AllocateSpareDialog.h src/ui/ActiveCueInfoPanel.h + src/ui/FxSetupDialog.h src/ui/PopOutWindow.h src/ui/BubbleButton.h src/ui/BubbleBar.h diff --git a/src/ui/FxSetupDialog.cpp b/src/ui/FxSetupDialog.cpp new file mode 100644 index 0000000..6d4b514 --- /dev/null +++ b/src/ui/FxSetupDialog.cpp @@ -0,0 +1,189 @@ +#include "FxSetupDialog.h" +#include "app/Application.h" +#include "core/FxLibrary.h" +#include "core/Show.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace OpenMix { + +namespace { +QList parseIntList(const QString& text) { + QList out; + for (const QString& tok : text.split(QRegularExpression("[,;\\s]+"), Qt::SkipEmptyParts)) { + bool ok = false; + int v = tok.toInt(&ok); + if (ok) + out.append(v); + } + return out; +} + +QString joinInts(const QList& list) { + QStringList parts; + for (int v : list) + parts.append(QString::number(v)); + return parts.join(", "); +} +} // namespace + +FxSetupDialog::FxSetupDialog(Application* app, QWidget* parent) : QDialog(parent), m_app(app) { + setWindowTitle(tr("FX Setup")); + setModal(true); + resize(460, 520); + setupUi(); + load(); +} + +void FxSetupDialog::setupUi() { + QVBoxLayout* mainLayout = new QVBoxLayout(this); + + QGroupBox* unitsGroup = new QGroupBox(tr("FX Units"), this); + QVBoxLayout* unitsLayout = new QVBoxLayout(unitsGroup); + m_unitsTable = new QTableWidget(0, 2, unitsGroup); + m_unitsTable->setHorizontalHeaderLabels({tr("Index"), tr("Name")}); + m_unitsTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + m_unitsTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + m_unitsTable->verticalHeader()->setVisible(false); + unitsLayout->addWidget(m_unitsTable); + QHBoxLayout* unitsButtons = new QHBoxLayout(); + QPushButton* addUnit = new QPushButton(tr("Add"), unitsGroup); + connect(addUnit, &QPushButton::clicked, this, &FxSetupDialog::addUnitRow); + QPushButton* removeUnit = new QPushButton(tr("Remove"), unitsGroup); + connect(removeUnit, &QPushButton::clicked, this, &FxSetupDialog::removeUnitRow); + unitsButtons->addStretch(); + unitsButtons->addWidget(addUnit); + unitsButtons->addWidget(removeUnit); + unitsLayout->addLayout(unitsButtons); + mainLayout->addWidget(unitsGroup); + + QGroupBox* assignGroup = new QGroupBox(tr("Channel Assignments"), this); + QVBoxLayout* assignLayout = new QVBoxLayout(assignGroup); + m_assignTable = new QTableWidget(0, 2, assignGroup); + m_assignTable->setHorizontalHeaderLabels({tr("Channel"), tr("FX Units")}); + m_assignTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + m_assignTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + m_assignTable->verticalHeader()->setVisible(false); + assignLayout->addWidget(m_assignTable); + QHBoxLayout* assignButtons = new QHBoxLayout(); + QPushButton* addAssign = new QPushButton(tr("Add"), assignGroup); + connect(addAssign, &QPushButton::clicked, this, &FxSetupDialog::addAssignRow); + QPushButton* removeAssign = new QPushButton(tr("Remove"), assignGroup); + connect(removeAssign, &QPushButton::clicked, this, &FxSetupDialog::removeAssignRow); + assignButtons->addStretch(); + assignButtons->addWidget(addAssign); + assignButtons->addWidget(removeAssign); + assignLayout->addLayout(assignButtons); + mainLayout->addWidget(assignGroup); + + QHBoxLayout* defaultRow = new QHBoxLayout(); + defaultRow->addWidget(new QLabel(tr("Default FX unit:"), this)); + m_defaultSpin = new QSpinBox(this); + m_defaultSpin->setRange(-1, 128); + m_defaultSpin->setSpecialValueText(tr("None")); + defaultRow->addWidget(m_defaultSpin); + defaultRow->addStretch(); + mainLayout->addLayout(defaultRow); + + QDialogButtonBox* buttons = + new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttons, &QDialogButtonBox::accepted, this, &FxSetupDialog::applyChanges); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + mainLayout->addWidget(buttons); +} + +void FxSetupDialog::load() { + if (!m_app || !m_app->show()) + return; + const FxLibrary* fx = m_app->show()->fxLibrary(); + + for (const FxUnit& unit : fx->units()) { + const int row = m_unitsTable->rowCount(); + m_unitsTable->insertRow(row); + m_unitsTable->setItem(row, 0, new QTableWidgetItem(QString::number(unit.index))); + m_unitsTable->setItem(row, 1, new QTableWidgetItem(unit.name)); + } + + const QMap> assigns = fx->assignments(); + for (auto it = assigns.begin(); it != assigns.end(); ++it) { + const int row = m_assignTable->rowCount(); + m_assignTable->insertRow(row); + m_assignTable->setItem(row, 0, new QTableWidgetItem(QString::number(it.key()))); + m_assignTable->setItem(row, 1, new QTableWidgetItem(joinInts(it.value()))); + } + + m_defaultSpin->setValue(fx->defaultFx()); +} + +void FxSetupDialog::addUnitRow() { + const int row = m_unitsTable->rowCount(); + m_unitsTable->insertRow(row); + m_unitsTable->setItem(row, 0, new QTableWidgetItem(QString::number(row + 1))); + m_unitsTable->setItem(row, 1, new QTableWidgetItem()); + m_unitsTable->editItem(m_unitsTable->item(row, 1)); +} + +void FxSetupDialog::removeUnitRow() { + const int row = m_unitsTable->currentRow(); + if (row >= 0) + m_unitsTable->removeRow(row); +} + +void FxSetupDialog::addAssignRow() { + const int row = m_assignTable->rowCount(); + m_assignTable->insertRow(row); + m_assignTable->setItem(row, 0, new QTableWidgetItem()); + m_assignTable->setItem(row, 1, new QTableWidgetItem()); + m_assignTable->editItem(m_assignTable->item(row, 0)); +} + +void FxSetupDialog::removeAssignRow() { + const int row = m_assignTable->currentRow(); + if (row >= 0) + m_assignTable->removeRow(row); +} + +void FxSetupDialog::applyChanges() { + if (!m_app || !m_app->show()) { + accept(); + return; + } + FxLibrary* fx = m_app->show()->fxLibrary(); + fx->clear(); + + for (int row = 0; row < m_unitsTable->rowCount(); ++row) { + QTableWidgetItem* indexItem = m_unitsTable->item(row, 0); + QTableWidgetItem* nameItem = m_unitsTable->item(row, 1); + if (!indexItem) + continue; + bool ok = false; + const int index = indexItem->text().toInt(&ok); + if (ok) + fx->setUnit(index, nameItem ? nameItem->text() : QString()); + } + + for (int row = 0; row < m_assignTable->rowCount(); ++row) { + QTableWidgetItem* channelItem = m_assignTable->item(row, 0); + QTableWidgetItem* unitsItem = m_assignTable->item(row, 1); + if (!channelItem) + continue; + bool ok = false; + const int channel = channelItem->text().toInt(&ok); + if (ok && unitsItem) + fx->setChannelAssignment(channel, parseIntList(unitsItem->text())); + } + + fx->setDefaultFx(m_defaultSpin->value()); + accept(); +} + +} // namespace OpenMix diff --git a/src/ui/FxSetupDialog.h b/src/ui/FxSetupDialog.h new file mode 100644 index 0000000..ed73125 --- /dev/null +++ b/src/ui/FxSetupDialog.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +class QSpinBox; +class QTableWidget; + +namespace OpenMix { + +class Application; + +// Edit the show's FX units (index -> name), per-channel FX assignments, and the +// default FX unit. Writes back to the show's FxLibrary on accept. +class FxSetupDialog : public QDialog { + Q_OBJECT + + public: + explicit FxSetupDialog(Application* app, QWidget* parent = nullptr); + + private slots: + void addUnitRow(); + void removeUnitRow(); + void addAssignRow(); + void removeAssignRow(); + void applyChanges(); + + private: + void setupUi(); + void load(); + + Application* m_app; + QTableWidget* m_unitsTable; + QTableWidget* m_assignTable; + QSpinBox* m_defaultSpin; +}; + +} // namespace OpenMix diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 2ba79a5..fa4adc4 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -6,6 +6,7 @@ #include "CueEditor.h" #include "ActiveCueInfoPanel.h" #include "AllocateSpareDialog.h" +#include "FxSetupDialog.h" #include "ChannelUtilisationDialog.h" #include "CueListView.h" #include "CueTableModel.h" @@ -347,6 +348,10 @@ void MainWindow::createActions() { tr("Console behavior, scribble highlight, channel monitor, and QLab settings")); connect(m_appSettingsAction, &QAction::triggered, this, &MainWindow::showSettingsDialog); + m_fxSetupAction = new QAction(tr("&FX Setup..."), this); + m_fxSetupAction->setToolTip(tr("Manage FX units and channel assignments")); + connect(m_fxSetupAction, &QAction::triggered, this, &MainWindow::showFxSetupDialog); + // help actions m_aboutAction = new QAction(tr("&About OpenMix"), this); m_aboutAction->setToolTip(tr("About this application")); @@ -519,6 +524,7 @@ void MainWindow::createMenus() { m_settingsMenu->addAction(m_midiControllerAction); m_settingsMenu->addAction(m_remoteControlAction); m_settingsMenu->addSeparator(); + m_settingsMenu->addAction(m_fxSetupAction); m_settingsMenu->addAction(m_appSettingsAction); m_helpMenu = menuBar()->addMenu(tr("&Help")); @@ -1243,6 +1249,11 @@ void MainWindow::showLogViewerDialog() { dialog.exec(); } +void MainWindow::showFxSetupDialog() { + FxSetupDialog dialog(m_app, this); + dialog.exec(); +} + void MainWindow::showEditHistoryDialog() { QDialog dialog(this); dialog.setWindowTitle(tr("Edit History")); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index bdf5160..a0e9936 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -94,6 +94,7 @@ class MainWindow : public QMainWindow { void showRemoteControlDialog(); void showKeyboardShortcutsDialog(); void showSettingsDialog(); + void showFxSetupDialog(); void showLogViewerDialog(); void showEditHistoryDialog(); void exportCuesToCsv(); @@ -219,6 +220,7 @@ class MainWindow : public QMainWindow { QAction* m_midiControllerAction; QAction* m_remoteControlAction; QAction* m_appSettingsAction; + QAction* m_fxSetupAction; // help actions QAction* m_aboutAction; From 7345385c52997b4d2fba60464e2e9086062a382e Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 09:58:38 -0400 Subject: [PATCH 26/92] fix: scroll cue editor sections and declutter timecode add row --- src/ui/CueEditor.cpp | 30 +++++++++++++++++++++--------- src/ui/TimecodePanel.cpp | 25 +++++++++++++++---------- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/ui/CueEditor.cpp b/src/ui/CueEditor.cpp index aaaff93..3175640 100644 --- a/src/ui/CueEditor.cpp +++ b/src/ui/CueEditor.cpp @@ -56,10 +56,13 @@ CueEditor::CueEditor(Application* app, QWidget* parent) : QWidget(parent), m_app } void CueEditor::setupUi() { - setMinimumSize(280, 500); + setMinimumWidth(280); setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Expanding); - m_mainLayout = new QVBoxLayout(this); + // the editor sections live inside a scroll area so a short pane scrolls + // rather than crushing the groups on top of each other + QWidget* content = new QWidget(this); + m_mainLayout = new QVBoxLayout(content); // basic properties group QGroupBox* basicGroup = new QGroupBox(tr("Cue Properties"), this); @@ -220,7 +223,7 @@ void CueEditor::setupUi() { m_mainLayout->addWidget(qlabGroup); // L/R gangs (show-level) + soundcheck (check) mode toggle - QGroupBox* rehearsalGroup = new QGroupBox(tr("Gangs & Rehearsal"), this); + QGroupBox* rehearsalGroup = new QGroupBox(tr("Gangs && Rehearsal"), this); QFormLayout* rehearsalLayout = new QFormLayout(rehearsalGroup); m_gangEdit = new QLineEdit(rehearsalGroup); m_gangEdit->setPlaceholderText(tr("L/R pairs, e.g. 1-2, 3-4")); @@ -244,6 +247,16 @@ void CueEditor::setupUi() { m_mainLayout->addStretch(); + QScrollArea* scroll = new QScrollArea(this); + scroll->setWidget(content); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + + QVBoxLayout* outerLayout = new QVBoxLayout(this); + outerLayout->setContentsMargins(0, 0, 0, 0); + outerLayout->addWidget(scroll); + // connect signals connect(m_numberSpin, QOverload::of(&QDoubleSpinBox::valueChanged), this, &CueEditor::onNumberChanged); @@ -270,7 +283,7 @@ void CueEditor::setupUi() { } void CueEditor::createFxMutesSection() { - m_fxMutesGroup = new QGroupBox(tr("FX Mutes & Snippets"), this); + m_fxMutesGroup = new QGroupBox(tr("FX Mutes && Snippets"), this); QVBoxLayout* layout = new QVBoxLayout(m_fxMutesGroup); layout->setContentsMargins(4, 4, 4, 4); @@ -311,7 +324,7 @@ void CueEditor::createFxMutesSection() { } void CueEditor::createChannelProfilesSection() { - m_channelProfilesGroup = new QGroupBox(tr("Channel Profiles & Levels"), this); + m_channelProfilesGroup = new QGroupBox(tr("Channel Profiles && Levels"), this); QVBoxLayout* layout = new QVBoxLayout(m_channelProfilesGroup); layout->setContentsMargins(4, 4, 4, 4); @@ -334,10 +347,9 @@ void CueEditor::createChannelProfilesSection() { } void CueEditor::addBottomWidget(QWidget* widget) { - if (m_mainLayout && widget) { - // insert before stretch - m_mainLayout->addWidget(widget); - } + // add below the scroll area so it stays fixed while the sections scroll + if (widget && layout()) + layout()->addWidget(widget); } void CueEditor::createDCATargetingSection() { diff --git a/src/ui/TimecodePanel.cpp b/src/ui/TimecodePanel.cpp index 6c29430..47dcd00 100644 --- a/src/ui/TimecodePanel.cpp +++ b/src/ui/TimecodePanel.cpp @@ -5,6 +5,7 @@ #include "theme/Icons.h" #include +#include #include #include #include @@ -79,9 +80,9 @@ void TimecodePanel::setupUi() { connect(m_table, &QTableWidget::cellChanged, this, &TimecodePanel::onCellChanged); mainLayout->addWidget(m_table, 1); - // add-trigger controls + // add-trigger controls: timecode spins on one row, cue + Add on the next QGroupBox* addGroup = new QGroupBox(tr("Add Trigger"), this); - QHBoxLayout* addLayout = new QHBoxLayout(addGroup); + QFormLayout* addForm = new QFormLayout(addGroup); auto makeSpin = [this](int max, const QString& suffix) { QSpinBox* spin = new QSpinBox(this); @@ -94,21 +95,25 @@ void TimecodePanel::setupUi() { m_minutesSpin = makeSpin(59, tr("m")); m_secondsSpin = makeSpin(59, tr("s")); m_framesSpin = makeSpin(29, tr("f")); - addLayout->addWidget(m_hoursSpin); - addLayout->addWidget(m_minutesSpin); - addLayout->addWidget(m_secondsSpin); - addLayout->addWidget(m_framesSpin); + QHBoxLayout* tcRow = new QHBoxLayout(); + tcRow->addWidget(m_hoursSpin); + tcRow->addWidget(m_minutesSpin); + tcRow->addWidget(m_secondsSpin); + tcRow->addWidget(m_framesSpin); + tcRow->addStretch(); + addForm->addRow(tr("Timecode:"), tcRow); - addLayout->addWidget(new QLabel(tr("Cue:"), this)); m_cueSpin = new QDoubleSpinBox(this); m_cueSpin->setRange(0.0, 99999.0); m_cueSpin->setDecimals(2); m_cueSpin->setSingleStep(1.0); - addLayout->addWidget(m_cueSpin); - m_addButton = new QPushButton(Icons::listAdd(), tr("Add"), this); connect(m_addButton, &QPushButton::clicked, this, &TimecodePanel::addTrigger); - addLayout->addWidget(m_addButton); + QHBoxLayout* cueRow = new QHBoxLayout(); + cueRow->addWidget(m_cueSpin); + cueRow->addStretch(); + cueRow->addWidget(m_addButton); + addForm->addRow(tr("Cue:"), cueRow); mainLayout->addWidget(addGroup); From 0d619101d1f62e228736c045b27dce8d8823675d Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 10:11:32 -0400 Subject: [PATCH 27/92] feat: startup welcome screen with recent shows --- CMakeLists.txt | 2 + src/main.cpp | 6 +- src/ui/MainWindow.cpp | 32 ++++++++++ src/ui/MainWindow.h | 1 + src/ui/WelcomeDialog.cpp | 125 +++++++++++++++++++++++++++++++++++++++ src/ui/WelcomeDialog.h | 39 ++++++++++++ 6 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 src/ui/WelcomeDialog.cpp create mode 100644 src/ui/WelcomeDialog.h diff --git a/CMakeLists.txt b/CMakeLists.txt index eb1f859..492d237 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -133,6 +133,7 @@ set(SOURCES src/ui/AllocateSpareDialog.cpp src/ui/ActiveCueInfoPanel.cpp src/ui/FxSetupDialog.cpp + src/ui/WelcomeDialog.cpp src/ui/PopOutWindow.cpp src/ui/BubbleButton.cpp src/ui/BubbleBar.cpp @@ -242,6 +243,7 @@ set(HEADERS src/ui/AllocateSpareDialog.h src/ui/ActiveCueInfoPanel.h src/ui/FxSetupDialog.h + src/ui/WelcomeDialog.h src/ui/PopOutWindow.h src/ui/BubbleButton.h src/ui/BubbleBar.h diff --git a/src/main.cpp b/src/main.cpp index f276fde..4098b36 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,5 +1,6 @@ #include "app/Application.h" #include "ui/MainWindow.h" +#include "ui/WelcomeDialog.h" #include "ui/theme/Theme.h" #include #include @@ -51,7 +52,10 @@ int main(int argc, char* argv[]) { OpenMix::MainWindow mainWindow(&app); app.setMainWindow(&mainWindow); mainWindow.show(); - QTimer::singleShot(0, &mainWindow, &OpenMix::MainWindow::openConnectionPanel); + if (OpenMix::WelcomeDialog::showAtStartup()) + QTimer::singleShot(0, &mainWindow, &OpenMix::MainWindow::showWelcomeDialog); + else + QTimer::singleShot(0, &mainWindow, &OpenMix::MainWindow::openConnectionPanel); QTimer::singleShot(500, &app, &OpenMix::Application::startupScan); return qtApp.exec(); diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index fa4adc4..5582a22 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -7,6 +7,7 @@ #include "ActiveCueInfoPanel.h" #include "AllocateSpareDialog.h" #include "FxSetupDialog.h" +#include "WelcomeDialog.h" #include "ChannelUtilisationDialog.h" #include "CueListView.h" #include "CueTableModel.h" @@ -979,6 +980,37 @@ void MainWindow::openConnectionPanel() { } } +void MainWindow::showWelcomeDialog() { + WelcomeDialog dialog(this); + if (dialog.exec() != QDialog::Accepted) + return; + + switch (dialog.choice()) { + case WelcomeDialog::Choice::NewShow: + newShow(); + break; + case WelcomeDialog::Choice::OpenShow: + openShow(); + break; + case WelcomeDialog::Choice::OpenRecent: { + if (!maybeSave()) + return; + QString error; + if (!ProjectFile::load(m_app->show(), dialog.recentPath(), &error)) { + QMessageBox::warning(this, tr("Error"), tr("Failed to open show:\n%1").arg(error)); + return; + } + m_cueEditor->setCue(-1); + updateTitle(); + updateStatusBar(); + updateRecentProjectsMenu(); + break; + } + case WelcomeDialog::Choice::None: + break; + } +} + void MainWindow::toggleConnectionPanel() { if (m_connectionPopOut->isVisible()) { m_connectionPopOut->hide(); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index a0e9936..40723c7 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -35,6 +35,7 @@ class MainWindow : public QMainWindow { ~MainWindow() override; void openConnectionPanel(); + void showWelcomeDialog(); protected: void closeEvent(QCloseEvent* event) override; diff --git a/src/ui/WelcomeDialog.cpp b/src/ui/WelcomeDialog.cpp new file mode 100644 index 0000000..dd60c61 --- /dev/null +++ b/src/ui/WelcomeDialog.cpp @@ -0,0 +1,125 @@ +#include "WelcomeDialog.h" +#include "io/ProjectFile.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace OpenMix { + +namespace { +constexpr int PathRole = Qt::UserRole + 1; +} + +WelcomeDialog::WelcomeDialog(QWidget* parent) : QDialog(parent) { + setWindowTitle(tr("Welcome to OpenMix")); + setModal(true); + resize(560, 460); + setupUi(); +} + +void WelcomeDialog::setupUi() { + QVBoxLayout* mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(28, 24, 28, 20); + mainLayout->setSpacing(6); + + QLabel* title = new QLabel(tr("OpenMix"), this); + QFont titleFont = title->font(); + titleFont.setPointSize(titleFont.pointSize() + 12); + titleFont.setBold(true); + title->setFont(titleFont); + mainLayout->addWidget(title); + + QLabel* subtitle = new QLabel(tr("Theatre mixing console control"), this); + subtitle->setEnabled(false); // muted + mainLayout->addWidget(subtitle); + mainLayout->addSpacing(16); + + // primary actions + QHBoxLayout* actions = new QHBoxLayout(); + QPushButton* newButton = new QPushButton(tr("New Show"), this); + newButton->setDefault(true); + newButton->setMinimumHeight(40); + connect(newButton, &QPushButton::clicked, this, &WelcomeDialog::chooseNew); + actions->addWidget(newButton); + + QPushButton* openButton = new QPushButton(tr("Open Show..."), this); + openButton->setMinimumHeight(40); + connect(openButton, &QPushButton::clicked, this, &WelcomeDialog::chooseOpen); + actions->addWidget(openButton); + mainLayout->addLayout(actions); + mainLayout->addSpacing(16); + + // recent shows + QLabel* recentLabel = new QLabel(tr("Recent Shows"), this); + mainLayout->addWidget(recentLabel); + + m_recentList = new QListWidget(this); + connect(m_recentList, &QListWidget::itemDoubleClicked, this, &WelcomeDialog::chooseRecent); + mainLayout->addWidget(m_recentList, 1); + + const QStringList recent = ProjectFile::recentProjects(); + if (recent.isEmpty()) { + QListWidgetItem* placeholder = new QListWidgetItem(tr("(No recent shows)"), m_recentList); + placeholder->setFlags(Qt::NoItemFlags); + } else { + for (const QString& path : recent) { + const QFileInfo info(path); + QListWidgetItem* item = + new QListWidgetItem(QString("%1\n%2").arg(info.fileName(), path), m_recentList); + item->setData(PathRole, path); + item->setToolTip(path); + } + } + + // footer: startup preference + open-recent + QHBoxLayout* footer = new QHBoxLayout(); + QCheckBox* startupCheck = new QCheckBox(tr("Show this screen at startup"), this); + startupCheck->setChecked(showAtStartup()); + connect(startupCheck, &QCheckBox::toggled, this, [](bool on) { + QSettings settings; + settings.setValue("Welcome/showAtStartup", on); + }); + footer->addWidget(startupCheck); + footer->addStretch(); + + QPushButton* openRecentButton = new QPushButton(tr("Open Selected"), this); + connect(openRecentButton, &QPushButton::clicked, this, &WelcomeDialog::chooseRecent); + footer->addWidget(openRecentButton); + mainLayout->addLayout(footer); +} + +void WelcomeDialog::chooseNew() { + m_choice = Choice::NewShow; + accept(); +} + +void WelcomeDialog::chooseOpen() { + m_choice = Choice::OpenShow; + accept(); +} + +void WelcomeDialog::chooseRecent() { + QListWidgetItem* item = m_recentList->currentItem(); + if (!item) + return; + const QString path = item->data(PathRole).toString(); + if (path.isEmpty()) + return; + m_choice = Choice::OpenRecent; + m_recentPath = path; + accept(); +} + +bool WelcomeDialog::showAtStartup() { + QSettings settings; + return settings.value("Welcome/showAtStartup", true).toBool(); +} + +} // namespace OpenMix diff --git a/src/ui/WelcomeDialog.h b/src/ui/WelcomeDialog.h new file mode 100644 index 0000000..4049e75 --- /dev/null +++ b/src/ui/WelcomeDialog.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +class QListWidget; + +namespace OpenMix { + +// Startup screen: create a new show, open one, or pick a recent show. The choice +// is read back by the caller after exec(). A "show at startup" preference is +// persisted so the dialog can be skipped on later launches. +class WelcomeDialog : public QDialog { + Q_OBJECT + + public: + enum class Choice { None, NewShow, OpenShow, OpenRecent }; + + explicit WelcomeDialog(QWidget* parent = nullptr); + + [[nodiscard]] Choice choice() const { return m_choice; } + [[nodiscard]] QString recentPath() const { return m_recentPath; } + + // startup preference (default true) + [[nodiscard]] static bool showAtStartup(); + + private slots: + void chooseNew(); + void chooseOpen(); + void chooseRecent(); + + private: + void setupUi(); + + Choice m_choice = Choice::None; + QString m_recentPath; + QListWidget* m_recentList; +}; + +} // namespace OpenMix From 1805805a693674bc8a2d842c46b0f181ac671ea3 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 10:17:13 -0400 Subject: [PATCH 28/92] chore: use repository description for app text and generic import labels --- src/io/TmixImporter.h | 2 +- src/ui/MainWindow.cpp | 8 ++++---- src/ui/WelcomeDialog.cpp | 6 +++++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/io/TmixImporter.h b/src/io/TmixImporter.h index 23ad83c..5be2aa3 100644 --- a/src/io/TmixImporter.h +++ b/src/io/TmixImporter.h @@ -6,7 +6,7 @@ namespace OpenMix { class Show; -// Imports a TheatreMix-format .tmix show file (a SQLite database) into a Show. +// Imports a .tmix show file (a SQLite database) into a Show. // The show is cleared first, then populated from the file's config, actors, // profiles, positions, ensembles and cues tables. Best-effort: missing tables // or columns are skipped rather than treated as errors. diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 5582a22..eff9530 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -115,7 +115,7 @@ void MainWindow::createActions() { m_openAction->setToolTip(tr("Open an existing show (Ctrl+O)")); connect(m_openAction, &QAction::triggered, this, &MainWindow::openShow); - m_importTmixAction = new QAction(tr("&Import TheatreMix Show..."), this); + m_importTmixAction = new QAction(tr("&Import Show File..."), this); m_importTmixAction->setToolTip(tr("Import a .tmix show file")); connect(m_importTmixAction, &QAction::triggered, this, &MainWindow::importTmixShow); @@ -360,9 +360,9 @@ void MainWindow::createActions() { QMessageBox::about( this, tr("About OpenMix"), tr("

OpenMix

" - "

Open Source Theatre Sound Mixing Control

" "

Version 0.1.0

" - "

A live-sound mixing control application for theatre productions.

")); + "

Stage mixing software for live theatre that lets engineers program, " + "automate, and run cues seamlessly across 30+ digital mixing consoles.

")); }); } @@ -869,7 +869,7 @@ void MainWindow::importTmixShow() { return; QString filePath = QFileDialog::getOpenFileName( - this, tr("Import TheatreMix Show"), QString(), tr("TheatreMix Show (*.tmix *.x32tc)")); + this, tr("Import Show File"), QString(), tr("Show Files (*.tmix *.x32tc)")); if (filePath.isEmpty()) return; diff --git a/src/ui/WelcomeDialog.cpp b/src/ui/WelcomeDialog.cpp index dd60c61..23c3ba3 100644 --- a/src/ui/WelcomeDialog.cpp +++ b/src/ui/WelcomeDialog.cpp @@ -36,7 +36,11 @@ void WelcomeDialog::setupUi() { title->setFont(titleFont); mainLayout->addWidget(title); - QLabel* subtitle = new QLabel(tr("Theatre mixing console control"), this); + QLabel* subtitle = new QLabel( + tr("Stage mixing software for live theatre that lets engineers program, automate, and " + "run cues seamlessly across 30+ digital mixing consoles."), + this); + subtitle->setWordWrap(true); subtitle->setEnabled(false); // muted mainLayout->addWidget(subtitle); mainLayout->addSpacing(16); From 0eaa8a4b71c71fe584734a2fb5dbbf8b832aaf0a Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 10:17:13 -0400 Subject: [PATCH 29/92] feat: cpack packaging with desktop entry and app icon --- CMakeLists.txt | 38 +++++++++++++++++++++++++++++++++++++- packaging/openmix.desktop | 10 ++++++++++ packaging/openmix.svg | 14 ++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 packaging/openmix.desktop create mode 100644 packaging/openmix.svg diff --git a/CMakeLists.txt b/CMakeLists.txt index 492d237..11320b9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,13 +2,15 @@ cmake_minimum_required(VERSION 3.16) project(OpenMix VERSION 0.1.0 - DESCRIPTION "Open Source Theatre Sound Mixing Control" + DESCRIPTION "Stage mixing software for live theatre that lets engineers program, automate, and run cues seamlessly across 30+ digital mixing consoles" LANGUAGES CXX ) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) +include(GNUInstallDirs) + set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) @@ -374,6 +376,40 @@ install(TARGETS ${PROJECT_NAME} RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} ) +# desktop entry + icon (Linux/BSD) +if(UNIX AND NOT APPLE) + install(FILES packaging/openmix.desktop + DESTINATION ${CMAKE_INSTALL_DATADIR}/applications) + install(FILES packaging/openmix.svg + DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/scalable/apps) +endif() + +# packaging (cpack): deb/rpm/tarball on Linux, NSIS on Windows, drag-drop dmg on macOS +set(CPACK_PACKAGE_NAME "OpenMix") +set(CPACK_PACKAGE_VENDOR "OpenMix") +set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "${PROJECT_DESCRIPTION}") +set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}") +set(CPACK_PACKAGE_INSTALL_DIRECTORY "OpenMix") +set(CPACK_PACKAGE_CONTACT "OpenMix") + +if(WIN32) + set(CPACK_GENERATOR "NSIS") + set(CPACK_NSIS_PACKAGE_NAME "OpenMix") + set(CPACK_NSIS_DISPLAY_NAME "OpenMix") + set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL ON) + set(CPACK_NSIS_MODIFY_PATH OFF) +elseif(APPLE) + set(CPACK_GENERATOR "DragNDrop") +else() + set(CPACK_GENERATOR "DEB;RPM;TGZ") + set(CPACK_DEBIAN_PACKAGE_SECTION "sound") + set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON) + set(CPACK_RPM_PACKAGE_LICENSE "MIT") + set(CPACK_RPM_PACKAGE_GROUP "Applications/Multimedia") +endif() + +include(CPack) + # enable testing enable_testing() add_subdirectory(tests) diff --git a/packaging/openmix.desktop b/packaging/openmix.desktop new file mode 100644 index 0000000..b2ca837 --- /dev/null +++ b/packaging/openmix.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=OpenMix +GenericName=Theatre Mixing Console Control +Comment=Fire theatre sound cues to digital mixing consoles +Exec=OpenMix %f +Icon=openmix +Terminal=false +Categories=AudioVideo;Audio; +StartupWMClass=OpenMix diff --git a/packaging/openmix.svg b/packaging/openmix.svg new file mode 100644 index 0000000..aa7115a --- /dev/null +++ b/packaging/openmix.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + From 5b268b55cc49185d7a3568b4ce87bb4070b96c71 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 10:48:58 -0400 Subject: [PATCH 30/92] feat: quick start and feature guide help --- CMakeLists.txt | 2 ++ src/ui/HelpDialog.cpp | 26 ++++++++++++++++++ src/ui/HelpDialog.h | 17 ++++++++++++ src/ui/MainWindow.cpp | 62 +++++++++++++++++++++++++++++++++++++++++++ src/ui/MainWindow.h | 4 +++ 5 files changed, 111 insertions(+) create mode 100644 src/ui/HelpDialog.cpp create mode 100644 src/ui/HelpDialog.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 11320b9..b55d231 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -136,6 +136,7 @@ set(SOURCES src/ui/ActiveCueInfoPanel.cpp src/ui/FxSetupDialog.cpp src/ui/WelcomeDialog.cpp + src/ui/HelpDialog.cpp src/ui/PopOutWindow.cpp src/ui/BubbleButton.cpp src/ui/BubbleBar.cpp @@ -246,6 +247,7 @@ set(HEADERS src/ui/ActiveCueInfoPanel.h src/ui/FxSetupDialog.h src/ui/WelcomeDialog.h + src/ui/HelpDialog.h src/ui/PopOutWindow.h src/ui/BubbleButton.h src/ui/BubbleBar.h diff --git a/src/ui/HelpDialog.cpp b/src/ui/HelpDialog.cpp new file mode 100644 index 0000000..0404358 --- /dev/null +++ b/src/ui/HelpDialog.cpp @@ -0,0 +1,26 @@ +#include "HelpDialog.h" + +#include +#include +#include + +namespace OpenMix { + +HelpDialog::HelpDialog(const QString& title, const QString& html, QWidget* parent) + : QDialog(parent) { + setWindowTitle(title); + resize(640, 620); + + QVBoxLayout* layout = new QVBoxLayout(this); + + QTextBrowser* browser = new QTextBrowser(this); + browser->setOpenExternalLinks(true); + browser->setHtml(html); + layout->addWidget(browser); + + QDialogButtonBox* buttons = new QDialogButtonBox(QDialogButtonBox::Close, this); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + layout->addWidget(buttons); +} + +} // namespace OpenMix diff --git a/src/ui/HelpDialog.h b/src/ui/HelpDialog.h new file mode 100644 index 0000000..e4cebd8 --- /dev/null +++ b/src/ui/HelpDialog.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +namespace OpenMix { + +// Simple in-app help viewer: renders a title + HTML body in a scrollable browser. +// Content is supplied by the caller (Quick Start, Feature Guide). +class HelpDialog : public QDialog { + Q_OBJECT + + public: + HelpDialog(const QString& title, const QString& html, QWidget* parent = nullptr); +}; + +} // namespace OpenMix diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index eff9530..4e76777 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -6,6 +6,7 @@ #include "CueEditor.h" #include "ActiveCueInfoPanel.h" #include "AllocateSpareDialog.h" +#include "HelpDialog.h" #include "FxSetupDialog.h" #include "WelcomeDialog.h" #include "ChannelUtilisationDialog.h" @@ -354,6 +355,14 @@ void MainWindow::createActions() { connect(m_fxSetupAction, &QAction::triggered, this, &MainWindow::showFxSetupDialog); // help actions + m_quickStartAction = new QAction(tr("&Quick Start"), this); + m_quickStartAction->setToolTip(tr("Get up and running in a few steps")); + connect(m_quickStartAction, &QAction::triggered, this, &MainWindow::showQuickStart); + + m_featureGuideAction = new QAction(tr("&Feature Guide"), this); + m_featureGuideAction->setToolTip(tr("Tour of the panels and features")); + connect(m_featureGuideAction, &QAction::triggered, this, &MainWindow::showFeatureGuide); + m_aboutAction = new QAction(tr("&About OpenMix"), this); m_aboutAction->setToolTip(tr("About this application")); connect(m_aboutAction, &QAction::triggered, [this]() { @@ -529,6 +538,9 @@ void MainWindow::createMenus() { m_settingsMenu->addAction(m_appSettingsAction); m_helpMenu = menuBar()->addMenu(tr("&Help")); + m_helpMenu->addAction(m_quickStartAction); + m_helpMenu->addAction(m_featureGuideAction); + m_helpMenu->addSeparator(); m_helpMenu->addAction(m_aboutAction); } @@ -1281,6 +1293,56 @@ void MainWindow::showLogViewerDialog() { dialog.exec(); } +void MainWindow::showQuickStart() { + const QString html = tr( + "

Quick Start

" + "
    " + "
  1. Connect your console. Open View → Connection (F7). Scan for an " + "OSC-capable console, or pick the protocol and enter its IP and port, then Connect.
  2. " + "
  3. Set up your cast. In View → Actor Setup (F9) add actors, assign " + "each to a channel, and store their voice (gain, HPF, EQ, dynamics) plus a backup " + "voice for a spare mic.
  4. " + "
  5. Build cues. Edit → Add Cue, then use the Cue Editor: DCA " + "targeting and labels, per-channel profiles, levels and positions, fade time and " + "curve, FX mutes and console snippets.
  6. " + "
  7. Run the show. Press Space to GO. Up/Down move the standby cue " + "without firing; Edit → Jump to Selected Cue sets standby to any cue.
  8. " + "
  9. Stay safe. Shift+Esc panics to the Cue Zero safe values. " + "Edit → Lock Editing prevents accidental edits during a performance.
  10. " + "
" + "

Already have a show file? File → Import Show File… reads a " + ".tmix database directly.

"); + HelpDialog dialog(tr("Quick Start"), html, this); + dialog.exec(); +} + +void MainWindow::showFeatureGuide() { + const QString html = tr( + "

Feature Guide

" + "

Cues

" + "

The cue list is the spine of the show. The Cue Editor edits everything a cue does; " + "the Edit menu adds clone, copy / paste / paste-merge / paste-swap, fill down, clone " + "offsets, jump and renumber.

" + "

Cast & voices

" + "

Actor Setup (F9) holds per-actor voice profiles with a backup copy. Ensembles " + "(F10) group channels onto a shared profile slot. Spare Backup " + "(Playback → Allocate Spare…) covers a failed mic and switches its " + "backup voice to a reserved channel, now or on the next cue.

" + "

Console control

" + "

DCA Mapping (F5) assigns channels and buses to DCAs. Positions (F11) store " + "pan / delay presets a cue can recall per channel. FX Setup names FX units and their " + "channel sends. Cue Zero holds the base/reset state.

" + "

External control

" + "

Settings → Remote Control covers MIDI Show Control, inbound OSC, and outbound " + "QLab/DAW with pre-roll. Timecode Triggers (F12) fire cues at an incoming timecode.

" + "

Monitoring & reporting

" + "

Mixer Feedback (F6) and Active Cue Info track live state. Channel Utilisation shows " + "which cues touch each channel; Export to CSV and Export Notes produce paperwork; Edit " + "History browses the undo stack.

"); + HelpDialog dialog(tr("Feature Guide"), html, this); + dialog.exec(); +} + void MainWindow::showFxSetupDialog() { FxSetupDialog dialog(m_app, this); dialog.exec(); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 40723c7..9529ac0 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -97,6 +97,8 @@ class MainWindow : public QMainWindow { void showSettingsDialog(); void showFxSetupDialog(); void showLogViewerDialog(); + void showQuickStart(); + void showFeatureGuide(); void showEditHistoryDialog(); void exportCuesToCsv(); void showChannelUtilisationDialog(); @@ -224,6 +226,8 @@ class MainWindow : public QMainWindow { QAction* m_fxSetupAction; // help actions + QAction* m_quickStartAction; + QAction* m_featureGuideAction; QAction* m_aboutAction; // status bar From 23980c2d1d4639aaee90334643caedcfc352ff69 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 10:51:43 -0400 Subject: [PATCH 31/92] chore: US spelling app-wide (utilization, center, behavior) --- CMakeLists.txt | 8 ++++---- ...lUtilisation.cpp => ChannelUtilization.cpp} | 4 ++-- ...annelUtilisation.h => ChannelUtilization.h} | 2 +- src/core/Position.h | 2 +- ...Dialog.cpp => ChannelUtilizationDialog.cpp} | 10 +++++----- ...tionDialog.h => ChannelUtilizationDialog.h} | 4 ++-- src/ui/MainWindow.cpp | 18 +++++++++--------- src/ui/MainWindow.h | 4 ++-- tests/CMakeLists.txt | 12 ++++++------ ...sation.cpp => test_channel_utilization.cpp} | 16 ++++++++-------- tests/test_position.cpp | 2 +- tests/test_show_control.cpp | 2 +- tests/test_tmix_import.cpp | 2 +- 13 files changed, 43 insertions(+), 43 deletions(-) rename src/core/{ChannelUtilisation.cpp => ChannelUtilization.cpp} (94%) rename src/core/{ChannelUtilisation.h => ChannelUtilization.h} (90%) rename src/ui/{ChannelUtilisationDialog.cpp => ChannelUtilizationDialog.cpp} (89%) rename src/ui/{ChannelUtilisationDialog.h => ChannelUtilizationDialog.h} (71%) rename tests/{test_channel_utilisation.cpp => test_channel_utilization.cpp} (74%) diff --git a/CMakeLists.txt b/CMakeLists.txt index b55d231..f00f82a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -104,7 +104,7 @@ set(SOURCES src/core/FadeEngine.cpp src/core/Position.cpp src/core/CueZero.cpp - src/core/ChannelUtilisation.cpp + src/core/ChannelUtilization.cpp src/core/TimecodeTrigger.cpp src/core/ChannelMonitor.cpp src/core/ScribbleController.cpp @@ -131,7 +131,7 @@ set(SOURCES src/ui/PositionPanel.cpp src/ui/TimecodePanel.cpp src/ui/CueZeroDialog.cpp - src/ui/ChannelUtilisationDialog.cpp + src/ui/ChannelUtilizationDialog.cpp src/ui/AllocateSpareDialog.cpp src/ui/ActiveCueInfoPanel.cpp src/ui/FxSetupDialog.cpp @@ -214,7 +214,7 @@ set(HEADERS src/core/FadeEngine.h src/core/Position.h src/core/CueZero.h - src/core/ChannelUtilisation.h + src/core/ChannelUtilization.h src/core/TimecodeTrigger.h src/core/ChannelMonitor.h src/core/ScribbleController.h @@ -242,7 +242,7 @@ set(HEADERS src/ui/PositionPanel.h src/ui/TimecodePanel.h src/ui/CueZeroDialog.h - src/ui/ChannelUtilisationDialog.h + src/ui/ChannelUtilizationDialog.h src/ui/AllocateSpareDialog.h src/ui/ActiveCueInfoPanel.h src/ui/FxSetupDialog.h diff --git a/src/core/ChannelUtilisation.cpp b/src/core/ChannelUtilization.cpp similarity index 94% rename from src/core/ChannelUtilisation.cpp rename to src/core/ChannelUtilization.cpp index ade4ed9..f8d49e7 100644 --- a/src/core/ChannelUtilisation.cpp +++ b/src/core/ChannelUtilization.cpp @@ -1,4 +1,4 @@ -#include "ChannelUtilisation.h" +#include "ChannelUtilization.h" #include "Cue.h" #include "CueList.h" #include "DCAMapping.h" @@ -10,7 +10,7 @@ namespace OpenMix { -QList computeChannelUtilisation(const Show* show) { +QList computeChannelUtilization(const Show* show) { QList result; if (!show) return result; diff --git a/src/core/ChannelUtilisation.h b/src/core/ChannelUtilization.h similarity index 90% rename from src/core/ChannelUtilisation.h rename to src/core/ChannelUtilization.h index ff5bb21..2c786a1 100644 --- a/src/core/ChannelUtilisation.h +++ b/src/core/ChannelUtilization.h @@ -16,6 +16,6 @@ struct ChannelUsage { // cues that use it. A cue uses a channel when the channel appears in the cue's // effective DCA mapping (its own custom mapping, else the show mapping) or in the // cue's per-channel profile / level / position assignments. Sorted by channel. -[[nodiscard]] QList computeChannelUtilisation(const Show* show); +[[nodiscard]] QList computeChannelUtilization(const Show* show); } // namespace OpenMix diff --git a/src/core/Position.h b/src/core/Position.h index cc2319a..049c4a5 100644 --- a/src/core/Position.h +++ b/src/core/Position.h @@ -31,7 +31,7 @@ class Position { [[nodiscard]] double delay() const noexcept { return m_delay; } void setDelay(double ms) { m_delay = ms; } - // pan, normalized -1.0 (full left) .. 0.0 (centre) .. +1.0 (full right) + // pan, normalized -1.0 (full left) .. 0.0 (center) .. +1.0 (full right) [[nodiscard]] double pan() const noexcept { return m_pan; } void setPan(double pan) { m_pan = pan; } diff --git a/src/ui/ChannelUtilisationDialog.cpp b/src/ui/ChannelUtilizationDialog.cpp similarity index 89% rename from src/ui/ChannelUtilisationDialog.cpp rename to src/ui/ChannelUtilizationDialog.cpp index 44d1789..20cca22 100644 --- a/src/ui/ChannelUtilisationDialog.cpp +++ b/src/ui/ChannelUtilizationDialog.cpp @@ -1,8 +1,8 @@ -#include "ChannelUtilisationDialog.h" +#include "ChannelUtilizationDialog.h" #include "app/Application.h" #include "core/Actor.h" #include "core/ActorProfileLibrary.h" -#include "core/ChannelUtilisation.h" +#include "core/ChannelUtilization.h" #include "core/Show.h" #include @@ -13,9 +13,9 @@ namespace OpenMix { -ChannelUtilisationDialog::ChannelUtilisationDialog(Application* app, QWidget* parent) +ChannelUtilizationDialog::ChannelUtilizationDialog(Application* app, QWidget* parent) : QDialog(parent) { - setWindowTitle(tr("Channel Utilisation")); + setWindowTitle(tr("Channel Utilization")); resize(520, 520); QVBoxLayout* layout = new QVBoxLayout(this); @@ -33,7 +33,7 @@ ChannelUtilisationDialog::ChannelUtilisationDialog(Application* app, QWidget* pa const ActorProfileLibrary* actors = app && app->show() ? app->show()->actorProfileLibrary() : nullptr; - const QList usage = computeChannelUtilisation(app ? app->show() : nullptr); + const QList usage = computeChannelUtilization(app ? app->show() : nullptr); table->setRowCount(usage.size()); for (int row = 0; row < usage.size(); ++row) { const ChannelUsage& u = usage.at(row); diff --git a/src/ui/ChannelUtilisationDialog.h b/src/ui/ChannelUtilizationDialog.h similarity index 71% rename from src/ui/ChannelUtilisationDialog.h rename to src/ui/ChannelUtilizationDialog.h index c136f87..3ddb24b 100644 --- a/src/ui/ChannelUtilisationDialog.h +++ b/src/ui/ChannelUtilizationDialog.h @@ -8,11 +8,11 @@ class Application; // Read-only report: each referenced input channel, its assigned actor, and the // cues that use it. Computed on open from the current show. -class ChannelUtilisationDialog : public QDialog { +class ChannelUtilizationDialog : public QDialog { Q_OBJECT public: - explicit ChannelUtilisationDialog(Application* app, QWidget* parent = nullptr); + explicit ChannelUtilizationDialog(Application* app, QWidget* parent = nullptr); }; } // namespace OpenMix diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 4e76777..f44ec81 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -9,7 +9,7 @@ #include "HelpDialog.h" #include "FxSetupDialog.h" #include "WelcomeDialog.h" -#include "ChannelUtilisationDialog.h" +#include "ChannelUtilizationDialog.h" #include "CueListView.h" #include "CueTableModel.h" #include "CueZeroDialog.h" @@ -321,10 +321,10 @@ void MainWindow::createActions() { m_exportCsvAction->setToolTip(tr("Export the cue list to a CSV file")); connect(m_exportCsvAction, &QAction::triggered, this, &MainWindow::exportCuesToCsv); - m_channelUtilisationAction = new QAction(tr("Channel &Utilisation"), this); - m_channelUtilisationAction->setToolTip(tr("Show which cues use each input channel")); - connect(m_channelUtilisationAction, &QAction::triggered, this, - &MainWindow::showChannelUtilisationDialog); + m_channelUtilizationAction = new QAction(tr("Channel &Utilization"), this); + m_channelUtilizationAction->setToolTip(tr("Show which cues use each input channel")); + connect(m_channelUtilizationAction, &QAction::triggered, this, + &MainWindow::showChannelUtilizationDialog); m_showLogViewerAction = new QAction(tr("Application &Log..."), this); m_showLogViewerAction->setShortcut(Qt::Key_F8); @@ -525,7 +525,7 @@ void MainWindow::createMenus() { m_viewMenu->addAction(m_cueZeroAction); m_viewMenu->addSeparator(); m_viewMenu->addAction(m_editHistoryAction); - m_viewMenu->addAction(m_channelUtilisationAction); + m_viewMenu->addAction(m_channelUtilizationAction); m_viewMenu->addAction(m_exportCsvAction); m_viewMenu->addAction(m_showLogViewerAction); @@ -1336,7 +1336,7 @@ void MainWindow::showFeatureGuide() { "

Settings → Remote Control covers MIDI Show Control, inbound OSC, and outbound " "QLab/DAW with pre-roll. Timecode Triggers (F12) fire cues at an incoming timecode.

" "

Monitoring & reporting

" - "

Mixer Feedback (F6) and Active Cue Info track live state. Channel Utilisation shows " + "

Mixer Feedback (F6) and Active Cue Info track live state. Channel Utilization shows " "which cues touch each channel; Export to CSV and Export Notes produce paperwork; Edit " "History browses the undo stack.

"); HelpDialog dialog(tr("Feature Guide"), html, this); @@ -1359,8 +1359,8 @@ void MainWindow::showEditHistoryDialog() { dialog.exec(); } -void MainWindow::showChannelUtilisationDialog() { - ChannelUtilisationDialog dialog(m_app, this); +void MainWindow::showChannelUtilizationDialog() { + ChannelUtilizationDialog dialog(m_app, this); dialog.exec(); } diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 9529ac0..def6fb2 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -101,7 +101,7 @@ class MainWindow : public QMainWindow { void showFeatureGuide(); void showEditHistoryDialog(); void exportCuesToCsv(); - void showChannelUtilisationDialog(); + void showChannelUtilizationDialog(); // bubble bar interaction void onBubbleButtonClicked(const QString& id, bool checked); @@ -215,7 +215,7 @@ class MainWindow : public QMainWindow { QAction* m_cueZeroAction; QAction* m_editHistoryAction; QAction* m_exportCsvAction; - QAction* m_channelUtilisationAction; + QAction* m_channelUtilizationAction; QAction* m_showLogViewerAction; // settings actions diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8b1e6f3..ad52d87 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -380,9 +380,9 @@ target_include_directories(test_tmix_import PRIVATE ) add_test(NAME TmixImportTest COMMAND test_tmix_import) -add_executable(test_channel_utilisation - test_channel_utilisation.cpp - ${CMAKE_SOURCE_DIR}/src/core/ChannelUtilisation.cpp +add_executable(test_channel_utilization + test_channel_utilization.cpp + ${CMAKE_SOURCE_DIR}/src/core/ChannelUtilization.cpp ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp ${CMAKE_SOURCE_DIR}/src/core/Show.cpp @@ -396,14 +396,14 @@ add_executable(test_channel_utilisation ${CMAKE_SOURCE_DIR}/src/core/CueZero.cpp ${CMAKE_SOURCE_DIR}/src/core/Ensemble.cpp ) -target_link_libraries(test_channel_utilisation PRIVATE +target_link_libraries(test_channel_utilization PRIVATE Qt6::Core Qt6::Test ) -target_include_directories(test_channel_utilisation PRIVATE +target_include_directories(test_channel_utilization PRIVATE ${CMAKE_SOURCE_DIR}/src ) -add_test(NAME ChannelUtilisationTest COMMAND test_channel_utilisation) +add_test(NAME ChannelUtilizationTest COMMAND test_channel_utilization) add_executable(test_spare_backup test_spare_backup.cpp diff --git a/tests/test_channel_utilisation.cpp b/tests/test_channel_utilization.cpp similarity index 74% rename from tests/test_channel_utilisation.cpp rename to tests/test_channel_utilization.cpp index 300e2d6..fb0e48b 100644 --- a/tests/test_channel_utilisation.cpp +++ b/tests/test_channel_utilization.cpp @@ -1,4 +1,4 @@ -#include "core/ChannelUtilisation.h" +#include "core/ChannelUtilization.h" #include "core/Cue.h" #include "core/CueList.h" #include "core/DCAMapping.h" @@ -8,7 +8,7 @@ using namespace OpenMix; -class TestChannelUtilisation : public QObject { +class TestChannelUtilization : public QObject { Q_OBJECT static QMap counts(const QList& usage) { @@ -32,7 +32,7 @@ class TestChannelUtilisation : public QObject { c2.setChannelLevel(6, 0.5); show.cueList()->addCue(c2); - const QMap c = counts(computeChannelUtilisation(&show)); + const QMap c = counts(computeChannelUtilization(&show)); QCOMPARE(c.value(5), 1); QCOMPARE(c.value(6), 2); // c1 via DCA mapping, c2 via level override QCOMPARE(c.value(7), 1); // c1 via profile @@ -44,16 +44,16 @@ class TestChannelUtilisation : public QObject { Cue c(1.0, "One"); // no custom mapping -> falls back to show mapping show.cueList()->addCue(c); - const QMap counts = TestChannelUtilisation::counts(computeChannelUtilisation(&show)); + const QMap counts = TestChannelUtilization::counts(computeChannelUtilization(&show)); QCOMPARE(counts.value(3), 1); } void testEmptyShow() { Show show; - QVERIFY(computeChannelUtilisation(&show).isEmpty()); - QVERIFY(computeChannelUtilisation(nullptr).isEmpty()); + QVERIFY(computeChannelUtilization(&show).isEmpty()); + QVERIFY(computeChannelUtilization(nullptr).isEmpty()); } }; -QTEST_MAIN(TestChannelUtilisation) -#include "test_channel_utilisation.moc" +QTEST_MAIN(TestChannelUtilization) +#include "test_channel_utilization.moc" diff --git a/tests/test_position.cpp b/tests/test_position.cpp index 2324f19..597bd06 100644 --- a/tests/test_position.cpp +++ b/tests/test_position.cpp @@ -27,7 +27,7 @@ class TestPosition : public QObject { } void position_defaultsAndEmptyBuses() { - Position p("Centre"); + Position p("Center"); const Position restored = Position::fromJson(p.toJson()); QCOMPARE(restored.pan(), 0.0); QCOMPARE(restored.delay(), 0.0); diff --git a/tests/test_show_control.cpp b/tests/test_show_control.cpp index b0cf438..eb390ef 100644 --- a/tests/test_show_control.cpp +++ b/tests/test_show_control.cpp @@ -10,7 +10,7 @@ using namespace OpenMix; namespace { -// records every driver call as a readable string so fire behaviour can be +// records every driver call as a readable string so fire behavior can be // asserted without real hardware. Always reports Connected once connect() runs. class RecordingMixer : public MixerProtocol { public: diff --git a/tests/test_tmix_import.cpp b/tests/test_tmix_import.cpp index fd631da..f723df4 100644 --- a/tests/test_tmix_import.cpp +++ b/tests/test_tmix_import.cpp @@ -33,7 +33,7 @@ class TestTmixImport : public QObject { QVERIFY(q.exec("CREATE TABLE positions (id INTEGER PRIMARY KEY, name TEXT, " "shortName TEXT, delay NUMERIC, pan NUMERIC)")); QVERIFY(q.exec("INSERT INTO positions (id,name,shortName,delay,pan) " - "VALUES(0,'Centre Stage','CS',12,-0.5)")); + "VALUES(0,'Center Stage','CS',12,-0.5)")); QVERIFY(q.exec("CREATE TABLE cues (number INTEGER, point INTEGER, name TEXT, " "dca01Channels TEXT, dca01Label TEXT)")); QVERIFY(q.exec("INSERT INTO cues (number,point,name,dca01Channels,dca01Label) " From 5fd0a48473b5ce1ddcab2ede1cb65c52bff99a0b Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 10:56:51 -0400 Subject: [PATCH 32/92] feat: record live console fader offsets into a cue --- src/protocol/MixerProtocol.h | 8 ++++++++ src/protocol/behringer/X32Protocol.cpp | 7 +++++++ src/protocol/behringer/X32Protocol.h | 1 + src/ui/CueListView.cpp | 27 ++++++++++++++++++++++++++ src/ui/CueListView.h | 3 ++- src/ui/MainWindow.cpp | 23 ++++++++++++++++++++++ src/ui/MainWindow.h | 2 ++ 7 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/protocol/MixerProtocol.h b/src/protocol/MixerProtocol.h index f2cb488..81b374c 100644 --- a/src/protocol/MixerProtocol.h +++ b/src/protocol/MixerProtocol.h @@ -6,6 +6,7 @@ #include #include #include +#include namespace OpenMix { @@ -63,6 +64,13 @@ class MixerProtocol : public QObject { virtual void setChannelName(int channel, const QString& name); virtual void setChannelColor(int channel, int color); + // read back a channel's current fader (normalized 0..1) 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*/) { + return std::nullopt; + } + virtual void refresh() = 0; virtual int latencyMs() const = 0; virtual const MixerCapabilities& capabilities() const; diff --git a/src/protocol/behringer/X32Protocol.cpp b/src/protocol/behringer/X32Protocol.cpp index db7f14f..1f270a3 100644 --- a/src/protocol/behringer/X32Protocol.cpp +++ b/src/protocol/behringer/X32Protocol.cpp @@ -275,6 +275,13 @@ void X32Protocol::setChannelFader(int channel, double level) { sendParameter(x32Channel(channel) + "/mix/fader", clampUnit(level)); } +std::optional X32Protocol::readChannelFader(int channel) { + const QVariant value = getParameter(x32Channel(channel) + "/mix/fader"); + if (!value.isValid()) + return std::nullopt; + return value.toDouble(); +} + void X32Protocol::setChannelMute(int channel, bool muted) { // /mix/on: 1 = on (unmuted), 0 = muted sendParameter(x32Channel(channel) + "/mix/on", muted ? 0 : 1); diff --git a/src/protocol/behringer/X32Protocol.h b/src/protocol/behringer/X32Protocol.h index 7503148..58e001d 100644 --- a/src/protocol/behringer/X32Protocol.h +++ b/src/protocol/behringer/X32Protocol.h @@ -55,6 +55,7 @@ class X32Protocol : public MixerProtocol { // semantic channel setters (used by actor-voice recall and fades) void setChannelFader(int channel, double level) override; + [[nodiscard]] std::optional readChannelFader(int channel) override; void setChannelMute(int channel, bool muted) override; void setChannelPreamp(int channel, double gainDb) override; void setChannelHpf(int channel, bool on, double freqHz) override; diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index f8c80ac..2383e09 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -10,6 +10,7 @@ #include "core/ShortcutManager.h" #include "core/Show.h" #include "core/UndoCommands.h" +#include "protocol/MixerProtocol.h" #include #include @@ -395,6 +396,32 @@ void CueListView::cloneOffsets() { selectSourceRow(idx + 1); } +int CueListView::recordOffsets() { + MixerProtocol* mixer = m_app->mixer(); + const int idx = selectedCueIndex(); + CueList* cueList = m_app->show()->cueList(); + if (!mixer || !mixer->isConnected() || idx < 0) + return 0; + + Cue oldCue = cueList->at(idx); + Cue newCue = oldCue; + int count = 0; + const int channels = mixer->capabilities().inputChannels; + for (int ch = 1; ch <= channels; ++ch) { + if (const std::optional level = mixer->readChannelFader(ch)) { + newCue.setChannelLevel(ch, *level); + ++count; + } + } + if (count == 0) + return 0; + + m_app->undoStack()->push(new EditCueCommand(cueList, idx, oldCue, newCue)); + cueList->updateCue(idx, newCue); + selectSourceRow(idx); + return count; +} + void CueListView::jumpToSelectedCue() { int idx = selectedCueIndex(); if (idx < 0) diff --git a/src/ui/CueListView.h b/src/ui/CueListView.h index 56d1c6f..051eab6 100644 --- a/src/ui/CueListView.h +++ b/src/ui/CueListView.h @@ -45,7 +45,8 @@ class CueListView : public QWidget { void pasteCueMerge(); // merge clipboard content into the selected cue void pasteCueSwap(); // exchange content between clipboard and selected cue void fillDown(); // copy the selected cue's content into the next cue - void cloneOffsets(); // copy just the selected cue's level offsets to the next cue + void cloneOffsets(); // copy just the selected cue's level offsets to the next cue + int recordOffsets(); // capture live console faders into the selected cue; returns count void jumpToSelectedCue(); // set the selected cue as standby without firing void setEditingLocked(bool locked); // make the cue table read-only void setRowHeight(int pixels); // cue-table row height diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index f44ec81..3a093c7 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -202,6 +202,11 @@ void MainWindow::createActions() { connect(m_cloneOffsetsAction, &QAction::triggered, this, [this]() { m_cueListView->cloneOffsets(); }); + m_recordOffsetsAction = new QAction(tr("&Record Offsets"), this); + m_recordOffsetsAction->setToolTip( + tr("Capture the console's current fader levels into the selected cue")); + connect(m_recordOffsetsAction, &QAction::triggered, this, &MainWindow::recordOffsets); + m_jumpToSelectedAction = new QAction(tr("&Jump to Selected Cue"), this); m_jumpToSelectedAction->setToolTip(tr("Set the selected cue as standby without firing")); connect(m_jumpToSelectedAction, &QAction::triggered, this, @@ -454,6 +459,7 @@ void MainWindow::createMenus() { m_editMenu->addAction(m_pasteSwapAction); m_editMenu->addAction(m_fillDownAction); m_editMenu->addAction(m_cloneOffsetsAction); + m_editMenu->addAction(m_recordOffsetsAction); m_editMenu->addSeparator(); m_editMenu->addAction(m_renumberAction); m_editMenu->addAction(m_jumpToSelectedAction); @@ -971,6 +977,22 @@ void MainWindow::showJumpDialog() { tr("No cue numbered %1.").arg(number)); } +void MainWindow::recordOffsets() { + if (!m_app->mixer() || !m_app->mixer()->isConnected()) { + QMessageBox::information(this, tr("Record Offsets"), tr("Connect to a console first.")); + return; + } + const int count = m_cueListView->recordOffsets(); + if (count == 0) { + QMessageBox::information( + this, tr("Record Offsets"), + tr("No fader values are available yet. Try again once the console has reported " + "its state.")); + } else { + statusBar()->showMessage(tr("Recorded %n channel offset(s)", "", count), 3000); + } +} + void MainWindow::toggleLockEditing() { m_editingLocked = m_lockEditingAction->isChecked(); m_cueListView->setEditingLocked(m_editingLocked); @@ -1125,6 +1147,7 @@ void MainWindow::updateActions() { m_pasteSwapAction->setEnabled(editable && hasSelection && m_cueListView->hasClipboardCue()); m_fillDownAction->setEnabled(editable && hasSelection); m_cloneOffsetsAction->setEnabled(editable && hasSelection); + m_recordOffsetsAction->setEnabled(editable && hasSelection); m_jumpToSelectedAction->setEnabled(hasSelection); m_jumpAction->setEnabled(hasCues); } diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index def6fb2..398baf9 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -57,6 +57,7 @@ class MainWindow : public QMainWindow { void renumberCues(); void showJumpDialog(); void toggleLockEditing(); + void recordOffsets(); // playback actions void go(); @@ -187,6 +188,7 @@ class MainWindow : public QMainWindow { QAction* m_pasteSwapAction; QAction* m_fillDownAction; QAction* m_cloneOffsetsAction; + QAction* m_recordOffsetsAction; QAction* m_jumpToSelectedAction; QAction* m_jumpAction; QAction* m_lockEditingAction; From 5e3a53c9f6b74efb6d6c2c84914abf18dc18fc2b Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 11:21:24 -0400 Subject: [PATCH 33/92] fix: replace welcome-screen blurb with a functional prompt --- src/ui/WelcomeDialog.cpp | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/ui/WelcomeDialog.cpp b/src/ui/WelcomeDialog.cpp index 23c3ba3..7b7c35c 100644 --- a/src/ui/WelcomeDialog.cpp +++ b/src/ui/WelcomeDialog.cpp @@ -36,11 +36,7 @@ void WelcomeDialog::setupUi() { title->setFont(titleFont); mainLayout->addWidget(title); - QLabel* subtitle = new QLabel( - tr("Stage mixing software for live theatre that lets engineers program, automate, and " - "run cues seamlessly across 30+ digital mixing consoles."), - this); - subtitle->setWordWrap(true); + QLabel* subtitle = new QLabel(tr("Open a recent show, or start a new one."), this); subtitle->setEnabled(false); // muted mainLayout->addWidget(subtitle); mainLayout->addSpacing(16); From 749b3154daff94980bbde14d201c1e85bfa175e9 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 11:32:12 -0400 Subject: [PATCH 34/92] refactor: remove unused programmer-mode gating --- CMakeLists.txt | 2 - src/app/Application.cpp | 3 - src/app/Application.h | 3 - src/core/OperationMode.cpp | 137 ------------------------------------- src/core/OperationMode.h | 69 ------------------- 5 files changed, 214 deletions(-) delete mode 100644 src/core/OperationMode.cpp delete mode 100644 src/core/OperationMode.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f00f82a..08bdd7f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -95,7 +95,6 @@ set(SOURCES src/core/PlaybackLogger.cpp src/core/DryRunEngine.cpp src/core/ShortcutManager.cpp - src/core/OperationMode.cpp src/core/DCAMapping.cpp src/core/ActorProfile.cpp src/core/Actor.cpp @@ -204,7 +203,6 @@ set(HEADERS src/core/PlaybackLogger.h src/core/DryRunEngine.h src/core/ShortcutManager.h - src/core/OperationMode.h src/core/DCAMapping.h src/core/ActorProfile.h src/core/Actor.h diff --git a/src/app/Application.cpp b/src/app/Application.cpp index d0b5b12..e69e5f0 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -13,7 +13,6 @@ #include "core/CueValidator.h" #include "core/DCAMapping.h" #include "core/DryRunEngine.h" -#include "core/OperationMode.h" #include "core/PlaybackEngine.h" #include "core/PlaybackGuard.h" #include "core/PlaybackLogger.h" @@ -53,7 +52,6 @@ Application::Application(QObject* parent) : QObject(parent) { m_dryRunEngine = new DryRunEngine(this); m_shortcutManager = new ShortcutManager(this); - m_operationModeManager = new OperationModeManager(this); m_crashRecovery = new CrashRecovery(this); @@ -174,7 +172,6 @@ void Application::initialize() { }); m_shortcutManager->loadFromSettings(); - m_operationModeManager->loadFromSettings(); // MIDI input setup m_midiInputManager->setPlaybackEngine(m_playbackEngine); diff --git a/src/app/Application.h b/src/app/Application.h index d47f541..f9fef01 100644 --- a/src/app/Application.h +++ b/src/app/Application.h @@ -16,7 +16,6 @@ class PlaybackGuard; class PlaybackLogger; class DryRunEngine; class ShortcutManager; -class OperationModeManager; class CrashRecovery; class MidiInputManager; class ConsoleDiscoveryService; @@ -54,7 +53,6 @@ class Application : public QObject { // operator experience [[nodiscard]] ShortcutManager* shortcutManager() { return m_shortcutManager; } - [[nodiscard]] OperationModeManager* operationModeManager() { return m_operationModeManager; } // recovery [[nodiscard]] CrashRecovery* crashRecovery() { return m_crashRecovery; } @@ -122,7 +120,6 @@ class Application : public QObject { // operator experience ShortcutManager* m_shortcutManager; - OperationModeManager* m_operationModeManager; // recovery CrashRecovery* m_crashRecovery; diff --git a/src/core/OperationMode.cpp b/src/core/OperationMode.cpp deleted file mode 100644 index dfe0408..0000000 --- a/src/core/OperationMode.cpp +++ /dev/null @@ -1,137 +0,0 @@ -#include "OperationMode.h" -#include - -namespace OpenMix { - -OperationModeManager::OperationModeManager(QObject* parent) : QObject(parent) {} - -void OperationModeManager::setMode(AppMode mode) { - if (m_mode != mode) { - m_mode = mode; - emit modeChanged(mode); - } -} - -QString OperationModeManager::modeString() const { return modeString(m_mode); } - -QString OperationModeManager::modeString(AppMode mode) { - switch (mode) { - case AppMode::Programmer: - return tr("Programmer"); - case AppMode::ShowMode: - return tr("Show Mode"); - } - return tr("Unknown"); -} - -bool OperationModeManager::requiresProgrammerMode(const QString& operation) { - if (m_mode != AppMode::Programmer) { - emit operationBlocked(operation); - return false; - } - return true; -} - -bool OperationModeManager::canEditCues() { return requiresProgrammerMode(tr("Edit cues")); } -bool OperationModeManager::canDeleteCues() { return requiresProgrammerMode(tr("Delete cues")); } -bool OperationModeManager::canModifyShow() { return requiresProgrammerMode(tr("Modify show")); } -bool OperationModeManager::canAddCues() { return requiresProgrammerMode(tr("Add cues")); } -bool OperationModeManager::canRenumberCues() { return requiresProgrammerMode(tr("Renumber cues")); } -bool OperationModeManager::canOpenShow() { return requiresProgrammerMode(tr("Open show")); } -bool OperationModeManager::canNewShow() { return requiresProgrammerMode(tr("New show")); } - -bool OperationModeManager::canSaveShow() const { return true; } - -bool OperationModeManager::canGo() const { return true; } - -bool OperationModeManager::canStop() const { return true; } - -bool OperationModeManager::canNavigateCues() const { return true; } - -bool OperationModeManager::canUsePanic() const { return true; } - -bool OperationModeManager::canViewTimeline() const { return true; } - -bool OperationModeManager::canViewMixerFeedback() const { return true; } - -void OperationModeManager::setShowModePassword(const QString& password) { - if (password.isEmpty()) { - m_passwordHash.clear(); - } else { - m_passwordHash = hashPassword(password); - } -} - -void OperationModeManager::clearPassword() { m_passwordHash.clear(); } - -bool OperationModeManager::validatePassword(const QString& password) const { - if (m_passwordHash.isEmpty()) { - return true; // no password set - } - return hashPassword(password) == m_passwordHash; -} - -bool OperationModeManager::switchToProgrammerMode(const QString& password) { - if (m_mode == AppMode::Programmer) { - return true; // already in programmer mode - } - - if (hasPassword()) { - if (password.isEmpty()) { - emit passwordRequired(); - return false; - } - - if (!validatePassword(password)) { - emit operationBlocked(tr("Invalid password")); - return false; - } - } - - setMode(AppMode::Programmer); - return true; -} - -void OperationModeManager::switchToShowMode() { setMode(AppMode::ShowMode); } - -void OperationModeManager::saveToSettings() { - QSettings settings("OpenMix", "OpenMix"); - settings.beginGroup("OperationMode"); - - // save password hash - settings.setValue("PasswordHash", m_passwordHash); - - settings.setValue("SaveModeEnabled", m_saveMode); - if (m_saveMode) { - settings.setValue("Mode", m_mode == AppMode::Programmer ? "programmer" : "showmode"); - } - - settings.endGroup(); -} - -void OperationModeManager::loadFromSettings() { - QSettings settings("OpenMix", "OpenMix"); - settings.beginGroup("OperationMode"); - - m_passwordHash = settings.value("PasswordHash").toString(); - - m_saveMode = settings.value("SaveModeEnabled", false).toBool(); - if (m_saveMode) { - QString savedMode = settings.value("Mode", "programmer").toString(); - if (savedMode == "showmode") { - m_mode = AppMode::ShowMode; - } else { - m_mode = AppMode::Programmer; - } - } - - settings.endGroup(); -} - -QString OperationModeManager::hashPassword(const QString& password) const { - QByteArray data = password.toUtf8(); - QByteArray hash = QCryptographicHash::hash(data, QCryptographicHash::Sha256); - return QString::fromLatin1(hash.toHex()); -} - -} // namespace OpenMix \ No newline at end of file diff --git a/src/core/OperationMode.h b/src/core/OperationMode.h deleted file mode 100644 index e22be24..0000000 --- a/src/core/OperationMode.h +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -#include -#include -#include - -namespace OpenMix { - -enum class AppMode { - Programmer, // full access to editing features - ShowMode // playback only -}; - -class OperationModeManager : public QObject { - Q_OBJECT - - public: - explicit OperationModeManager(QObject* parent = nullptr); - - [[nodiscard]] AppMode currentMode() const noexcept { return m_mode; } - void setMode(AppMode mode); - - [[nodiscard]] QString modeString() const; - [[nodiscard]] static QString modeString(AppMode mode); - - [[nodiscard]] bool canEditCues(); // programmer only - [[nodiscard]] bool canDeleteCues(); // programmer only - [[nodiscard]] bool canModifyShow(); // programmer only - [[nodiscard]] bool canAddCues(); // programmer only - [[nodiscard]] bool canRenumberCues(); // programmer only - [[nodiscard]] bool canOpenShow(); // programmer only - [[nodiscard]] bool canNewShow(); // programmer only - [[nodiscard]] bool canSaveShow() const; // always (emergency save) - [[nodiscard]] bool canGo() const; // always - [[nodiscard]] bool canStop() const; // always - [[nodiscard]] bool canNavigateCues() const; // always - [[nodiscard]] bool canUsePanic() const; // always - [[nodiscard]] bool canViewTimeline() const; // always - [[nodiscard]] bool canViewMixerFeedback() const; // always - - void setShowModePassword(const QString& password); - void clearPassword(); - [[nodiscard]] bool hasPassword() const noexcept { return !m_passwordHash.isEmpty(); } - [[nodiscard]] bool validatePassword(const QString& password) const; - - bool switchToProgrammerMode(const QString& password = QString()); - void switchToShowMode(); - - void saveToSettings(); - void loadFromSettings(); - - void setSaveMode(bool enabled) { m_saveMode = enabled; } - [[nodiscard]] bool saveMode() const noexcept { return m_saveMode; } - - signals: - void modeChanged(AppMode mode); - void operationBlocked(const QString& operation); - void passwordRequired(); - - private: - bool requiresProgrammerMode(const QString& operation); - QString hashPassword(const QString& password) const; - - AppMode m_mode = AppMode::Programmer; - QString m_passwordHash; - bool m_saveMode = false; -}; - -} // namespace OpenMix From 747d51b7d00b947f9936d969d98b85e08c4953e9 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 12:00:41 -0400 Subject: [PATCH 35/92] feat: QLab panic/stop/pause/resume transport verbs --- src/app/QLabClient.cpp | 8 ++++++++ src/app/QLabClient.h | 5 +++++ src/ui/MainWindow.cpp | 4 ++++ tests/test_qlab_client.cpp | 29 +++++++++++++++++++++++++++++ 4 files changed, 46 insertions(+) diff --git a/src/app/QLabClient.cpp b/src/app/QLabClient.cpp index 4edb914..73e38ce 100644 --- a/src/app/QLabClient.cpp +++ b/src/app/QLabClient.cpp @@ -70,6 +70,14 @@ void QLabClient::triggerCue(const QString& cueId) { void QLabClient::go() { send(prefix() + QStringLiteral("/go")); } +void QLabClient::panic() { send(prefix() + QStringLiteral("/panic")); } + +void QLabClient::stop() { send(prefix() + QStringLiteral("/stop")); } + +void QLabClient::pause() { send(prefix() + QStringLiteral("/pause")); } + +void QLabClient::resume() { send(prefix() + QStringLiteral("/resume")); } + void QLabClient::back() { if (m_suppressBack) return; diff --git a/src/app/QLabClient.h b/src/app/QLabClient.h index d059178..520fa63 100644 --- a/src/app/QLabClient.h +++ b/src/app/QLabClient.h @@ -51,6 +51,11 @@ class QLabClient : public QObject { void go(); // step the QLab playhead back one cue (no-op when suppressBack is set) void back(); + // transport controls on the QLab workspace + void panic(); + void stop(); + void pause(); + void resume(); signals: void sent(const QString& address); diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 3a093c7..7e5e465 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -23,6 +23,7 @@ #include "RemoteControlDialog.h" #include "SettingsDialog.h" #include "app/Application.h" +#include "app/QLabClient.h" #include "core/AppLogger.h" #include "core/CueList.h" #include "core/CueValidator.h" @@ -1244,6 +1245,9 @@ void MainWindow::panic() { // fallback: just stop playback m_app->playbackEngine()->stop(); } + // also panic a linked QLab workspace so external playback stops with us + if (m_app->qLabClient() && m_app->qLabClient()->isEnabled()) + m_app->qLabClient()->panic(); } void MainWindow::panicRestore() { diff --git a/tests/test_qlab_client.cpp b/tests/test_qlab_client.cpp index 3c27d79..afb1c19 100644 --- a/tests/test_qlab_client.cpp +++ b/tests/test_qlab_client.cpp @@ -119,6 +119,35 @@ class TestQLabClient : public QObject { lo_server_thread_stop(srv); lo_server_thread_free(srv); } + + void transportVerbsSendAddresses() { + QLabClient client; + client.setTarget("127.0.0.1", 53000); + client.setEnabled(true); + + QSignalSpy sent(&client, &QLabClient::sent); + client.panic(); + client.stop(); + client.pause(); + client.resume(); + + QCOMPARE(sent.count(), 4); + QCOMPARE(sent.at(0).at(0).toString(), QString("/panic")); + QCOMPARE(sent.at(1).at(0).toString(), QString("/stop")); + QCOMPARE(sent.at(2).at(0).toString(), QString("/pause")); + QCOMPARE(sent.at(3).at(0).toString(), QString("/resume")); + } + + void workspacePrefixesTransport() { + QLabClient client; + client.setTarget("127.0.0.1", 53000); + client.setEnabled(true); + client.setWorkspaceId("ABC"); + + QSignalSpy sent(&client, &QLabClient::sent); + client.panic(); + QCOMPARE(sent.at(0).at(0).toString(), QString("/workspace/ABC/panic")); + } }; QTEST_MAIN(TestQLabClient) From 08dada3bc00201b4853f61fd13e79bcc9036bda2 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 12:01:46 -0400 Subject: [PATCH 36/92] feat: discard notes action --- src/ui/MainWindow.cpp | 22 ++++++++++++++++++++++ src/ui/MainWindow.h | 2 ++ 2 files changed, 24 insertions(+) diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 7e5e465..9f9a233 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -135,6 +135,10 @@ void MainWindow::createActions() { m_exportNotesAction->setToolTip(tr("Export cue notes to a text file")); connect(m_exportNotesAction, &QAction::triggered, this, &MainWindow::exportNotes); + m_discardNotesAction = new QAction(tr("&Discard Notes"), this); + m_discardNotesAction->setToolTip(tr("Clear the notes from every cue")); + connect(m_discardNotesAction, &QAction::triggered, this, &MainWindow::discardNotes); + m_exitAction = new QAction(Icons::appExit(), tr("E&xit"), this); m_exitAction->setShortcut(QKeySequence::Quit); m_exitAction->setToolTip(tr("Exit the application")); @@ -442,6 +446,7 @@ void MainWindow::createMenus() { m_fileMenu->addAction(m_saveAsAction); m_fileMenu->addSeparator(); m_fileMenu->addAction(m_exportNotesAction); + m_fileMenu->addAction(m_discardNotesAction); m_fileMenu->addSeparator(); m_fileMenu->addAction(m_exitAction); @@ -1391,6 +1396,23 @@ void MainWindow::showChannelUtilizationDialog() { dialog.exec(); } +void MainWindow::discardNotes() { + if (QMessageBox::question(this, tr("Discard Notes"), + tr("Clear the notes from every cue? This cannot be undone.")) != + QMessageBox::Yes) + return; + + CueList* list = m_app->show()->cueList(); + for (int i = 0; i < list->count(); ++i) { + if (list->at(i).notes().isEmpty()) + continue; + Cue cue = list->at(i); + cue.setNotes(QString()); + list->updateCue(i, cue); + } + m_cueEditor->setCue(m_cueListView->selectedCueIndex()); +} + void MainWindow::exportNotes() { QString filePath = QFileDialog::getSaveFileName(this, tr("Export Notes"), QString(), tr("Text Files (*.txt)")); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 398baf9..82b4890 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -50,6 +50,7 @@ class MainWindow : public QMainWindow { void saveShow(); void saveShowAs(); void exportNotes(); + void discardNotes(); // edit menu actions void addCue(); @@ -172,6 +173,7 @@ class MainWindow : public QMainWindow { QAction* m_saveAction; QAction* m_saveAsAction; QAction* m_exportNotesAction; + QAction* m_discardNotesAction; QAction* m_exitAction; // edit actions From 7100ad4bf29693cf98f54b1e92c8155a12eba0e0 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 12:04:09 -0400 Subject: [PATCH 37/92] feat: inline DCA, position, and FX cue-table columns --- src/ui/CueTableModel.cpp | 28 ++++++++++++++++++++++++++++ src/ui/CueTableModel.h | 14 +++++++++++++- src/ui/MainWindow.cpp | 15 ++++++++++----- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/ui/CueTableModel.cpp b/src/ui/CueTableModel.cpp index 39db903..6d691bf 100644 --- a/src/ui/CueTableModel.cpp +++ b/src/ui/CueTableModel.cpp @@ -63,6 +63,28 @@ QVariant CueTableModel::data(const QModelIndex& index, int role) const { return cue.notes(); case ColColor: return cue.color(); + case ColDca: { + QStringList parts; + const QMap overrides = cue.dcaOverrides(); + for (auto it = overrides.begin(); it != overrides.end(); ++it) { + if (it.value().label.has_value()) + parts << QString("%1:%2").arg(it.key()).arg(*it.value().label); + } + return parts.join(", "); + } + case ColPosition: { + const int count = cue.channelPositions().size(); + return count > 0 ? tr("%n ch", "", count) : QString(); + } + case ColFx: { + QStringList parts; + const QMap mutes = cue.fxMutes(); + for (auto it = mutes.begin(); it != mutes.end(); ++it) { + if (it.value()) + parts << QString::number(it.key()); + } + return parts.isEmpty() ? QString() : tr("mute %1").arg(parts.join(", ")); + } } } @@ -136,6 +158,12 @@ QVariant CueTableModel::headerData(int section, Qt::Orientation orientation, int return tr("Notes"); case ColColor: return tr("Color"); + case ColDca: + return tr("DCAs"); + case ColPosition: + return tr("Positions"); + case ColFx: + return tr("FX"); } return QVariant(); } diff --git a/src/ui/CueTableModel.h b/src/ui/CueTableModel.h index 73d68f4..b6f3681 100644 --- a/src/ui/CueTableModel.h +++ b/src/ui/CueTableModel.h @@ -14,7 +14,19 @@ class CueTableModel : public QAbstractTableModel { public: // ColColor is appended last so existing column indices stay stable for views // that assign per-column delegates/widths by name. - enum Column { ColNumber = 0, ColName, ColType, ColGroup, ColTags, ColNotes, ColColor, ColCount }; + enum Column { + ColNumber = 0, + ColName, + ColType, + ColGroup, + ColTags, + ColNotes, + ColColor, + ColDca, // read-only summary of DCA overrides (channel labels) + ColPosition, // read-only count of positioned channels + ColFx, // read-only muted FX units + ColCount + }; explicit CueTableModel(CueList* cueList, QObject* parent = nullptr); diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 9f9a233..c6fa9ba 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -520,16 +520,21 @@ void MainWindow::createMenus() { struct ColumnToggle { const char* label; int column; + bool visible; // default visibility }; - const ColumnToggle columnToggles[] = {{"&Group", CueTableModel::ColGroup}, - {"&Tags", CueTableModel::ColTags}, - {"&Notes", CueTableModel::ColNotes}, - {"Colo&r", CueTableModel::ColColor}}; + const ColumnToggle columnToggles[] = {{"&Group", CueTableModel::ColGroup, true}, + {"&Tags", CueTableModel::ColTags, true}, + {"&Notes", CueTableModel::ColNotes, true}, + {"Colo&r", CueTableModel::ColColor, true}, + {"&DCAs", CueTableModel::ColDca, false}, + {"&Positions", CueTableModel::ColPosition, false}, + {"&FX", CueTableModel::ColFx, false}}; for (const ColumnToggle& ct : columnToggles) { QAction* action = columnsMenu->addAction(tr(ct.label)); action->setCheckable(true); - action->setChecked(true); + action->setChecked(ct.visible); const int column = ct.column; + m_cueListView->setColumnVisible(column, ct.visible); // apply default connect(action, &QAction::toggled, this, [this, column](bool on) { m_cueListView->setColumnVisible(column, on); }); } From d28c60ba88eb12042db5231f3b7a47a3339ba5aa Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 12:10:13 -0400 Subject: [PATCH 38/92] feat: REAPER virtual-sound-check marker sync --- CMakeLists.txt | 2 + src/app/Application.cpp | 12 +++++ src/app/Application.h | 3 ++ src/app/ReaperClient.cpp | 97 ++++++++++++++++++++++++++++++++++ src/app/ReaperClient.h | 71 +++++++++++++++++++++++++ src/ui/RemoteControlDialog.cpp | 39 ++++++++++++++ src/ui/RemoteControlDialog.h | 6 +++ tests/CMakeLists.txt | 8 +++ tests/test_reaper_client.cpp | 64 ++++++++++++++++++++++ 9 files changed, 302 insertions(+) create mode 100644 src/app/ReaperClient.cpp create mode 100644 src/app/ReaperClient.h create mode 100644 tests/test_reaper_client.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 08bdd7f..2d13722 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -83,6 +83,7 @@ set(SOURCES src/app/Application.cpp src/app/OscRemoteServer.cpp src/app/QLabClient.cpp + src/app/ReaperClient.cpp src/core/Cue.cpp src/core/CueList.cpp src/core/Show.cpp @@ -191,6 +192,7 @@ set(HEADERS src/app/Application.h src/app/OscRemoteServer.h src/app/QLabClient.h + src/app/ReaperClient.h src/core/Cue.h src/core/CueList.h src/core/Show.h diff --git a/src/app/Application.cpp b/src/app/Application.cpp index e69e5f0..3585899 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -1,6 +1,7 @@ #include "Application.h" #include "OscRemoteServer.h" #include "QLabClient.h" +#include "ReaperClient.h" #include "ui/MainWindow.h" #include "core/AppLogger.h" #include "core/ChannelMonitor.h" @@ -69,6 +70,7 @@ Application::Application(QObject* parent) : QObject(parent) { // outbound QLab / DAW remote m_qLabClient = new QLabClient(this); + m_reaperClient = new ReaperClient(this); // timecode triggers + channel monitor m_timecodeTriggers = new TimecodeTriggerList(this); @@ -206,6 +208,16 @@ void Application::initialize() { m_qLabClient->triggerCue(cue.qLabCue()); }); + // REAPER virtual sound check: drop/jump a marker on each cue fire + m_reaperClient->loadFromSettings(); + connect(m_playbackEngine, &PlaybackEngine::cueExecuted, this, [this](int index) { + if (!m_reaperClient->isEnabled() || index < 0 || !m_show->cueList() || + index >= m_show->cueList()->count()) + return; + const Cue& cue = m_show->cueList()->at(index); + m_reaperClient->onCueFired(cue.number(), cue.name()); + }); + // timecode-triggered cues: incoming MTC -> trigger list -> fire cue by number connect(m_midiInputManager, &MidiInputManager::timecodeChanged, m_timecodeTriggers, &TimecodeTriggerList::onTimecode); diff --git a/src/app/Application.h b/src/app/Application.h index f9fef01..48531bc 100644 --- a/src/app/Application.h +++ b/src/app/Application.h @@ -23,6 +23,7 @@ class AppLogger; class ConnectionLogBridge; class OscRemoteServer; class QLabClient; +class ReaperClient; class TimecodeTriggerList; class ChannelMonitor; class ScribbleController; @@ -71,6 +72,7 @@ class Application : public QObject { // outbound QLab / DAW remote [[nodiscard]] QLabClient* qLabClient() { return m_qLabClient; } + [[nodiscard]] ReaperClient* reaperClient() { return m_reaperClient; } // timecode triggers + channel monitor [[nodiscard]] TimecodeTriggerList* timecodeTriggers() { return m_timecodeTriggers; } @@ -139,6 +141,7 @@ class Application : public QObject { // outbound QLab / DAW remote QLabClient* m_qLabClient; + ReaperClient* m_reaperClient; // timecode-triggered cues + channel silence/clip monitoring TimecodeTriggerList* m_timecodeTriggers; diff --git a/src/app/ReaperClient.cpp b/src/app/ReaperClient.cpp new file mode 100644 index 0000000..fd13dc3 --- /dev/null +++ b/src/app/ReaperClient.cpp @@ -0,0 +1,97 @@ +#include "ReaperClient.h" + +#include +#include + +namespace OpenMix { + +ReaperClient::ReaperClient(QObject* parent) : QObject(parent) { rebuildAddress(); } + +ReaperClient::~ReaperClient() { + if (m_address) + lo_address_free(m_address); +} + +void ReaperClient::setTarget(const QString& host, int port) { + m_host = host; + m_port = port > 0 ? port : REAPER_DEFAULT_PORT; + rebuildAddress(); +} + +void ReaperClient::rebuildAddress() { + if (m_address) { + lo_address_free(m_address); + m_address = nullptr; + } + m_address = + lo_address_new(m_host.toUtf8().constData(), QByteArray::number(m_port).constData()); +} + +void ReaperClient::send(const QString& address) { + if (!m_enabled || !m_address) + return; + lo_send(m_address, address.toUtf8().constData(), "f", 1.0f); // bang / trigger + emit sent(address); +} + +void ReaperClient::sendString(const QString& address, const QString& value) { + if (!m_enabled || !m_address) + return; + lo_send(m_address, address.toUtf8().constData(), "s", value.toUtf8().constData()); + emit sent(address); +} + +void ReaperClient::placeMarker(double cueNumber, const QString& name) { + // insert a marker at the play cursor, then name it after the cue + send(QStringLiteral("/action/%1").arg(ACTION_INSERT_MARKER)); + const QString label = name.isEmpty() ? QString::number(cueNumber, 'f', 2) : name; + sendString(QStringLiteral("/lastmarker/name"), label); + m_markers.insert(cueNumber, m_nextMarker); + ++m_nextMarker; +} + +void ReaperClient::jumpToMarker(int index) { + // go to the marker; pre-roll (m_preRollSeconds) is applied once REAPER marker + // times are available via feedback -- see header note. + send(QStringLiteral("/marker/%1").arg(index)); +} + +void ReaperClient::onCueFired(double cueNumber, const QString& name) { + if (!m_enabled) + return; + if (m_recordMode) { + placeMarker(cueNumber, name); + } else if (m_markers.contains(cueNumber)) { + jumpToMarker(m_markers.value(cueNumber)); + } +} + +void ReaperClient::resetMarkers() { + m_markers.clear(); + m_nextMarker = 1; +} + +void ReaperClient::loadFromSettings() { + QSettings settings; + settings.beginGroup("Reaper"); + m_host = settings.value("host", m_host).toString(); + m_port = settings.value("port", m_port).toInt(); + m_enabled = settings.value("enabled", false).toBool(); + m_recordMode = settings.value("recordMode", false).toBool(); + m_preRollSeconds = settings.value("preRollSeconds", 0).toInt(); + settings.endGroup(); + rebuildAddress(); +} + +void ReaperClient::saveToSettings() { + QSettings settings; + settings.beginGroup("Reaper"); + settings.setValue("host", m_host); + settings.setValue("port", m_port); + settings.setValue("enabled", m_enabled); + settings.setValue("recordMode", m_recordMode); + settings.setValue("preRollSeconds", m_preRollSeconds); + settings.endGroup(); +} + +} // namespace OpenMix diff --git a/src/app/ReaperClient.h b/src/app/ReaperClient.h new file mode 100644 index 0000000..dcc7842 --- /dev/null +++ b/src/app/ReaperClient.h @@ -0,0 +1,71 @@ +#pragma once + +#include +#include +#include +#include + +namespace OpenMix { + +// REAPER link for virtual sound check: on each cue fire, either drop a REAPER +// marker (record mode) or jump the REAPER playhead to that cue's marker +// (playback mode), so sections can be rehearsed against a recorded multitrack. +// Talks REAPER's OSC dialect; a live REAPER session is needed to exercise it. +class ReaperClient : public QObject { + Q_OBJECT + + public: + // REAPER's default OSC receive port. + static constexpr int REAPER_DEFAULT_PORT = 8000; + // REAPER action id: "Markers: Insert marker at current position". + static constexpr int ACTION_INSERT_MARKER = 40157; + + explicit ReaperClient(QObject* parent = nullptr); + ~ReaperClient() override; + + void setTarget(const QString& host, int port); + [[nodiscard]] QString host() const { return m_host; } + [[nodiscard]] int port() const { return m_port; } + + void setEnabled(bool enabled) { m_enabled = enabled; } + [[nodiscard]] bool isEnabled() const { return m_enabled; } + + // record mode drops markers on fire; playback mode jumps to them. + void setRecordMode(bool recording) { m_recordMode = recording; } + [[nodiscard]] bool recordMode() const { return m_recordMode; } + + // marker pre-roll in seconds, applied when jumping (playback mode). + void setPreRollSeconds(int seconds) { m_preRollSeconds = seconds < 0 ? 0 : seconds; } + [[nodiscard]] int preRollSeconds() const { return m_preRollSeconds; } + + void loadFromSettings(); + void saveToSettings(); + + public slots: + // route a fired cue: drop or jump a marker depending on the mode. + void onCueFired(double cueNumber, const QString& name); + // forget the cue->marker mapping (e.g. on new REAPER session). + void resetMarkers(); + + signals: + void sent(const QString& address); + + private: + void rebuildAddress(); + void send(const QString& address); + void sendString(const QString& address, const QString& value); + void placeMarker(double cueNumber, const QString& name); + void jumpToMarker(int index); + + lo_address m_address = nullptr; + QString m_host = QStringLiteral("127.0.0.1"); + int m_port = REAPER_DEFAULT_PORT; + bool m_enabled = false; + bool m_recordMode = false; + int m_preRollSeconds = 0; + + QHash m_markers; // cue number -> marker index + int m_nextMarker = 1; +}; + +} // namespace OpenMix diff --git a/src/ui/RemoteControlDialog.cpp b/src/ui/RemoteControlDialog.cpp index fc42443..1657bb8 100644 --- a/src/ui/RemoteControlDialog.cpp +++ b/src/ui/RemoteControlDialog.cpp @@ -2,6 +2,7 @@ #include "app/Application.h" #include "app/OscRemoteServer.h" #include "app/QLabClient.h" +#include "app/ReaperClient.h" #include "midi/MidiInputManager.h" #include @@ -77,6 +78,25 @@ void RemoteControlDialog::setupUi() { qlabForm->addRow(tr("Workspace:"), m_qlabWorkspace); layout->addWidget(qlabBox); + // --- REAPER virtual sound check --------------------------------------- + auto* reaperBox = new QGroupBox(tr("REAPER (Virtual Sound Check)"), this); + auto* reaperForm = new QFormLayout(reaperBox); + m_reaperEnabled = new QCheckBox(tr("Link to REAPER"), reaperBox); + m_reaperHost = new QLineEdit(reaperBox); + m_reaperHost->setPlaceholderText(tr("REAPER IP address")); + m_reaperPort = new QSpinBox(reaperBox); + m_reaperPort->setRange(1, 65535); + m_reaperRecord = new QCheckBox(tr("Record markers (unchecked jumps to them)"), reaperBox); + m_reaperPreRoll = new QSpinBox(reaperBox); + m_reaperPreRoll->setRange(0, 60); + m_reaperPreRoll->setSuffix(tr(" s")); + reaperForm->addRow(m_reaperEnabled); + reaperForm->addRow(tr("Host:"), m_reaperHost); + reaperForm->addRow(tr("Port:"), m_reaperPort); + reaperForm->addRow(m_reaperRecord); + reaperForm->addRow(tr("Marker pre-roll:"), m_reaperPreRoll); + layout->addWidget(reaperBox); + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); connect(buttons, &QDialogButtonBox::accepted, this, &RemoteControlDialog::accept); connect(buttons, &QDialogButtonBox::rejected, this, &RemoteControlDialog::reject); @@ -103,6 +123,14 @@ void RemoteControlDialog::loadValues() { m_qlabPreRoll->setValue(qlab->preRollMs()); m_qlabWorkspace->setText(qlab->workspaceId()); } + + if (ReaperClient* reaper = m_app->reaperClient()) { + m_reaperEnabled->setChecked(reaper->isEnabled()); + m_reaperHost->setText(reaper->host()); + m_reaperPort->setValue(reaper->port()); + m_reaperRecord->setChecked(reaper->recordMode()); + m_reaperPreRoll->setValue(reaper->preRollSeconds()); + } } void RemoteControlDialog::applyValues() { @@ -135,6 +163,17 @@ void RemoteControlDialog::applyValues() { qlab->setWorkspaceId(m_qlabWorkspace->text().trimmed()); qlab->saveToSettings(); } + + if (ReaperClient* reaper = m_app->reaperClient()) { + reaper->setEnabled(m_reaperEnabled->isChecked()); + reaper->setTarget(m_reaperHost->text().trimmed().isEmpty() + ? QStringLiteral("127.0.0.1") + : m_reaperHost->text().trimmed(), + m_reaperPort->value()); + reaper->setRecordMode(m_reaperRecord->isChecked()); + reaper->setPreRollSeconds(m_reaperPreRoll->value()); + reaper->saveToSettings(); + } } void RemoteControlDialog::accept() { diff --git a/src/ui/RemoteControlDialog.h b/src/ui/RemoteControlDialog.h index 04279f9..eb55795 100644 --- a/src/ui/RemoteControlDialog.h +++ b/src/ui/RemoteControlDialog.h @@ -46,6 +46,12 @@ class RemoteControlDialog : public QDialog { QSpinBox* m_qlabPort = nullptr; QSpinBox* m_qlabPreRoll = nullptr; QLineEdit* m_qlabWorkspace = nullptr; + + QCheckBox* m_reaperEnabled = nullptr; + QLineEdit* m_reaperHost = nullptr; + QSpinBox* m_reaperPort = nullptr; + QCheckBox* m_reaperRecord = nullptr; + QSpinBox* m_reaperPreRoll = nullptr; }; } // namespace OpenMix diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ad52d87..1b23583 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -158,6 +158,14 @@ target_link_libraries(test_qlab_client PRIVATE Qt6::Core Qt6::Test PkgConfig::LI target_include_directories(test_qlab_client PRIVATE ${CMAKE_SOURCE_DIR}/src) add_test(NAME QLabClientTest COMMAND test_qlab_client) +add_executable(test_reaper_client + test_reaper_client.cpp + ${CMAKE_SOURCE_DIR}/src/app/ReaperClient.cpp +) +target_link_libraries(test_reaper_client PRIVATE Qt6::Core Qt6::Test PkgConfig::LIBLO) +target_include_directories(test_reaper_client PRIVATE ${CMAKE_SOURCE_DIR}/src) +add_test(NAME ReaperClientTest COMMAND test_reaper_client) + add_executable(test_undo_commands test_undo_commands.cpp ${CMAKE_SOURCE_DIR}/src/core/UndoCommands.cpp diff --git a/tests/test_reaper_client.cpp b/tests/test_reaper_client.cpp new file mode 100644 index 0000000..b435274 --- /dev/null +++ b/tests/test_reaper_client.cpp @@ -0,0 +1,64 @@ +#include "app/ReaperClient.h" +#include +#include + +using namespace OpenMix; + +class TestReaperClient : public QObject { + Q_OBJECT + + private slots: + void recordModeDropsAndNamesMarker() { + ReaperClient c; + c.setTarget("127.0.0.1", 8000); + c.setEnabled(true); + c.setRecordMode(true); + + QSignalSpy sent(&c, &ReaperClient::sent); + c.onCueFired(1.0, "Opening"); + + QCOMPARE(sent.count(), 2); + QCOMPARE(sent.at(0).at(0).toString(), QString("/action/40157")); // insert marker + QCOMPARE(sent.at(1).at(0).toString(), QString("/lastmarker/name")); + } + + void playbackJumpsToRecordedMarker() { + ReaperClient c; + c.setTarget("127.0.0.1", 8000); + c.setEnabled(true); + + c.setRecordMode(true); + c.onCueFired(5.0, "Five"); // records marker index 1 for cue 5 + + c.setRecordMode(false); + QSignalSpy sent(&c, &ReaperClient::sent); + c.onCueFired(5.0, "Five"); // playback -> jump to marker 1 + + QCOMPARE(sent.count(), 1); + QCOMPARE(sent.at(0).at(0).toString(), QString("/marker/1")); + } + + void playbackUnknownCueIsNoOp() { + ReaperClient c; + c.setTarget("127.0.0.1", 8000); + c.setEnabled(true); + c.setRecordMode(false); + + QSignalSpy sent(&c, &ReaperClient::sent); + c.onCueFired(9.0, "Nine"); // never recorded + QCOMPARE(sent.count(), 0); + } + + void disabledSendsNothing() { + ReaperClient c; + c.setTarget("127.0.0.1", 8000); + c.setRecordMode(true); + + QSignalSpy sent(&c, &ReaperClient::sent); + c.onCueFired(1.0, "x"); + QCOMPARE(sent.count(), 0); + } +}; + +QTEST_MAIN(TestReaperClient) +#include "test_reaper_client.moc" From 9a65e7a46c310590b7c3e90959aa5faa92be6886 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 12:33:35 -0400 Subject: [PATCH 39/92] feat: marker notes with HTML export --- CMakeLists.txt | 2 + src/app/ReaperClient.cpp | 20 ++++- src/app/ReaperClient.h | 21 ++++- src/ui/MainWindow.cpp | 11 +++ src/ui/MainWindow.h | 2 + src/ui/MarkerNotesDialog.cpp | 144 +++++++++++++++++++++++++++++++++++ src/ui/MarkerNotesDialog.h | 33 ++++++++ tests/test_reaper_client.cpp | 19 +++++ 8 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 src/ui/MarkerNotesDialog.cpp create mode 100644 src/ui/MarkerNotesDialog.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 2d13722..d6a01d4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -137,6 +137,7 @@ set(SOURCES src/ui/FxSetupDialog.cpp src/ui/WelcomeDialog.cpp src/ui/HelpDialog.cpp + src/ui/MarkerNotesDialog.cpp src/ui/PopOutWindow.cpp src/ui/BubbleButton.cpp src/ui/BubbleBar.cpp @@ -248,6 +249,7 @@ set(HEADERS src/ui/FxSetupDialog.h src/ui/WelcomeDialog.h src/ui/HelpDialog.h + src/ui/MarkerNotesDialog.h src/ui/PopOutWindow.h src/ui/BubbleButton.h src/ui/BubbleBar.h diff --git a/src/app/ReaperClient.cpp b/src/app/ReaperClient.cpp index fd13dc3..d312664 100644 --- a/src/app/ReaperClient.cpp +++ b/src/app/ReaperClient.cpp @@ -46,8 +46,9 @@ void ReaperClient::placeMarker(double cueNumber, const QString& name) { send(QStringLiteral("/action/%1").arg(ACTION_INSERT_MARKER)); const QString label = name.isEmpty() ? QString::number(cueNumber, 'f', 2) : name; sendString(QStringLiteral("/lastmarker/name"), label); - m_markers.insert(cueNumber, m_nextMarker); + m_markers.append({cueNumber, label, m_nextMarker, QString()}); ++m_nextMarker; + emit markersChanged(); } void ReaperClient::jumpToMarker(int index) { @@ -61,14 +62,27 @@ void ReaperClient::onCueFired(double cueNumber, const QString& name) { return; if (m_recordMode) { placeMarker(cueNumber, name); - } else if (m_markers.contains(cueNumber)) { - jumpToMarker(m_markers.value(cueNumber)); + return; + } + for (const MarkerEntry& marker : m_markers) { + if (marker.cueNumber == cueNumber) { + jumpToMarker(marker.index); + return; + } } } +void ReaperClient::setMarkerNoteAt(int row, const QString& note) { + if (row < 0 || row >= m_markers.size()) + return; + m_markers[row].note = note; + emit markersChanged(); +} + void ReaperClient::resetMarkers() { m_markers.clear(); m_nextMarker = 1; + emit markersChanged(); } void ReaperClient::loadFromSettings() { diff --git a/src/app/ReaperClient.h b/src/app/ReaperClient.h index dcc7842..ec87983 100644 --- a/src/app/ReaperClient.h +++ b/src/app/ReaperClient.h @@ -1,6 +1,6 @@ #pragma once -#include +#include #include #include #include @@ -20,6 +20,14 @@ class ReaperClient : public QObject { // REAPER action id: "Markers: Insert marker at current position". static constexpr int ACTION_INSERT_MARKER = 40157; + // one dropped marker in the current sound-check session, plus its note. + struct MarkerEntry { + double cueNumber = 0.0; + QString cueName; + int index = 0; + QString note; + }; + explicit ReaperClient(QObject* parent = nullptr); ~ReaperClient() override; @@ -41,12 +49,19 @@ class ReaperClient : public QObject { void loadFromSettings(); void saveToSettings(); + // session markers dropped so far, in order, with their notes. + [[nodiscard]] QList markers() const { return m_markers; } + void setMarkerNoteAt(int row, const QString& note); + public slots: // route a fired cue: drop or jump a marker depending on the mode. void onCueFired(double cueNumber, const QString& name); - // forget the cue->marker mapping (e.g. on new REAPER session). + // forget the session markers (e.g. on new REAPER session). void resetMarkers(); + signals: + void markersChanged(); + signals: void sent(const QString& address); @@ -64,7 +79,7 @@ class ReaperClient : public QObject { bool m_recordMode = false; int m_preRollSeconds = 0; - QHash m_markers; // cue number -> marker index + QList m_markers; // session markers, in fire order int m_nextMarker = 1; }; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index c6fa9ba..df07e04 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -10,6 +10,7 @@ #include "FxSetupDialog.h" #include "WelcomeDialog.h" #include "ChannelUtilizationDialog.h" +#include "MarkerNotesDialog.h" #include "CueListView.h" #include "CueTableModel.h" #include "CueZeroDialog.h" @@ -336,6 +337,10 @@ void MainWindow::createActions() { connect(m_channelUtilizationAction, &QAction::triggered, this, &MainWindow::showChannelUtilizationDialog); + m_markerNotesAction = new QAction(tr("&Marker Notes..."), this); + m_markerNotesAction->setToolTip(tr("Review, annotate, and export REAPER sound-check markers")); + connect(m_markerNotesAction, &QAction::triggered, this, &MainWindow::showMarkerNotesDialog); + m_showLogViewerAction = new QAction(tr("Application &Log..."), this); m_showLogViewerAction->setShortcut(Qt::Key_F8); m_showLogViewerAction->setToolTip(tr("Show application log (F8)")); @@ -543,6 +548,7 @@ void MainWindow::createMenus() { m_viewMenu->addSeparator(); m_viewMenu->addAction(m_editHistoryAction); m_viewMenu->addAction(m_channelUtilizationAction); + m_viewMenu->addAction(m_markerNotesAction); m_viewMenu->addAction(m_exportCsvAction); m_viewMenu->addAction(m_showLogViewerAction); @@ -1396,6 +1402,11 @@ void MainWindow::showEditHistoryDialog() { dialog.exec(); } +void MainWindow::showMarkerNotesDialog() { + MarkerNotesDialog dialog(m_app, this); + dialog.exec(); +} + void MainWindow::showChannelUtilizationDialog() { ChannelUtilizationDialog dialog(m_app, this); dialog.exec(); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 82b4890..2f9640e 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -104,6 +104,7 @@ class MainWindow : public QMainWindow { void showEditHistoryDialog(); void exportCuesToCsv(); void showChannelUtilizationDialog(); + void showMarkerNotesDialog(); // bubble bar interaction void onBubbleButtonClicked(const QString& id, bool checked); @@ -220,6 +221,7 @@ class MainWindow : public QMainWindow { QAction* m_editHistoryAction; QAction* m_exportCsvAction; QAction* m_channelUtilizationAction; + QAction* m_markerNotesAction; QAction* m_showLogViewerAction; // settings actions diff --git a/src/ui/MarkerNotesDialog.cpp b/src/ui/MarkerNotesDialog.cpp new file mode 100644 index 0000000..5527be1 --- /dev/null +++ b/src/ui/MarkerNotesDialog.cpp @@ -0,0 +1,144 @@ +#include "MarkerNotesDialog.h" +#include "app/Application.h" +#include "app/ReaperClient.h" +#include "core/Show.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace OpenMix { + +MarkerNotesDialog::MarkerNotesDialog(Application* app, QWidget* parent) + : QDialog(parent), m_app(app) { + setWindowTitle(tr("Marker Notes")); + resize(560, 480); + setupUi(); + reload(); + + if (m_app && m_app->reaperClient()) + connect(m_app->reaperClient(), &ReaperClient::markersChanged, this, + &MarkerNotesDialog::reload); +} + +void MarkerNotesDialog::setupUi() { + QVBoxLayout* layout = new QVBoxLayout(this); + + QLabel* intro = new QLabel( + tr("Markers dropped during REAPER sound check. Add notes, then export for paperwork."), + this); + intro->setWordWrap(true); + layout->addWidget(intro); + + m_table = new QTableWidget(0, 3, this); + m_table->setHorizontalHeaderLabels({tr("Cue"), tr("Name"), tr("Note")}); + m_table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + m_table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::ResizeToContents); + m_table->horizontalHeader()->setSectionResizeMode(2, QHeaderView::Stretch); + m_table->verticalHeader()->setVisible(false); + connect(m_table, &QTableWidget::cellChanged, this, &MarkerNotesDialog::onCellChanged); + layout->addWidget(m_table); + + QHBoxLayout* buttonRow = new QHBoxLayout(); + QPushButton* exportButton = new QPushButton(tr("Export to HTML..."), this); + connect(exportButton, &QPushButton::clicked, this, &MarkerNotesDialog::exportHtml); + QPushButton* clearButton = new QPushButton(tr("Clear Session"), this); + connect(clearButton, &QPushButton::clicked, this, &MarkerNotesDialog::clearSession); + buttonRow->addWidget(exportButton); + buttonRow->addWidget(clearButton); + buttonRow->addStretch(); + QDialogButtonBox* buttons = new QDialogButtonBox(QDialogButtonBox::Close, this); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + buttonRow->addWidget(buttons); + layout->addLayout(buttonRow); +} + +void MarkerNotesDialog::reload() { + if (!m_app || !m_app->reaperClient()) + return; + const QList markers = m_app->reaperClient()->markers(); + + m_updating = true; + m_table->setRowCount(markers.size()); + for (int row = 0; row < markers.size(); ++row) { + const ReaperClient::MarkerEntry& marker = markers.at(row); + auto* cueItem = new QTableWidgetItem(QString::number(marker.cueNumber, 'f', 2)); + cueItem->setFlags(cueItem->flags() & ~Qt::ItemIsEditable); + m_table->setItem(row, 0, cueItem); + auto* nameItem = new QTableWidgetItem(marker.cueName); + nameItem->setFlags(nameItem->flags() & ~Qt::ItemIsEditable); + m_table->setItem(row, 1, nameItem); + m_table->setItem(row, 2, new QTableWidgetItem(marker.note)); + } + m_updating = false; +} + +void MarkerNotesDialog::onCellChanged(int row, int column) { + if (m_updating || column != 2 || !m_app || !m_app->reaperClient()) + return; + QTableWidgetItem* item = m_table->item(row, column); + m_app->reaperClient()->setMarkerNoteAt(row, item ? item->text() : QString()); +} + +void MarkerNotesDialog::exportHtml() { + if (!m_app || !m_app->reaperClient()) + return; + const QList markers = m_app->reaperClient()->markers(); + if (markers.isEmpty()) { + QMessageBox::information(this, tr("Export Marker Notes"), + tr("There are no markers to export.")); + return; + } + + QString filePath = QFileDialog::getSaveFileName(this, tr("Export Marker Notes"), QString(), + tr("HTML Files (*.html)")); + if (filePath.isEmpty()) + return; + if (!filePath.endsWith(".html", Qt::CaseInsensitive)) + filePath += ".html"; + + QFile file(filePath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + QMessageBox::warning(this, tr("Error"), tr("Could not write %1").arg(filePath)); + return; + } + + const QString showName = m_app->show() ? m_app->show()->name() : tr("Show"); + QTextStream out(&file); + out << "\n\n" + << "\n"; + out << "

" << showName.toHtmlEscaped() << " — Marker Notes

\n"; + out << "\n"; + for (const ReaperClient::MarkerEntry& marker : markers) { + out << "\n"; + } + out << "
CueNameNote
" << QString::number(marker.cueNumber, 'f', 2) << "" + << marker.cueName.toHtmlEscaped() << "" << marker.note.toHtmlEscaped() + << "
\n\n"; + file.close(); + + QMessageBox::information(this, tr("Export Marker Notes"), + tr("Marker notes exported to %1").arg(filePath)); +} + +void MarkerNotesDialog::clearSession() { + if (!m_app || !m_app->reaperClient()) + return; + if (QMessageBox::question(this, tr("Clear Session"), + tr("Discard all markers and notes from this session?")) != + QMessageBox::Yes) + return; + m_app->reaperClient()->resetMarkers(); +} + +} // namespace OpenMix diff --git a/src/ui/MarkerNotesDialog.h b/src/ui/MarkerNotesDialog.h new file mode 100644 index 0000000..9132a90 --- /dev/null +++ b/src/ui/MarkerNotesDialog.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +class QTableWidget; + +namespace OpenMix { + +class Application; + +// Review and annotate the REAPER virtual-sound-check markers dropped this +// session, and export them (with notes) to an HTML file. +class MarkerNotesDialog : public QDialog { + Q_OBJECT + + public: + explicit MarkerNotesDialog(Application* app, QWidget* parent = nullptr); + + private slots: + void reload(); + void onCellChanged(int row, int column); + void exportHtml(); + void clearSession(); + + private: + void setupUi(); + + Application* m_app; + QTableWidget* m_table; + bool m_updating = false; +}; + +} // namespace OpenMix diff --git a/tests/test_reaper_client.cpp b/tests/test_reaper_client.cpp index b435274..dcab48d 100644 --- a/tests/test_reaper_client.cpp +++ b/tests/test_reaper_client.cpp @@ -49,6 +49,25 @@ class TestReaperClient : public QObject { QCOMPARE(sent.count(), 0); } + void markersCarryEditableNotes() { + ReaperClient c; + c.setTarget("127.0.0.1", 8000); + c.setEnabled(true); + c.setRecordMode(true); + c.onCueFired(1.0, "Opening"); + c.onCueFired(2.0, "Second"); + + QCOMPARE(c.markers().size(), 2); + QCOMPARE(c.markers().at(0).cueName, QString("Opening")); + QCOMPARE(c.markers().at(1).index, 2); + + c.setMarkerNoteAt(1, "tighten reverb"); + QCOMPARE(c.markers().at(1).note, QString("tighten reverb")); + + c.resetMarkers(); + QVERIFY(c.markers().isEmpty()); + } + void disabledSendsNothing() { ReaperClient c; c.setTarget("127.0.0.1", 8000); From 38b34f5504792150559c929f64b8ba4d4b83bc38 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 12:47:45 -0400 Subject: [PATCH 40/92] feat: REAPER transport auto-detect via OSC feedback --- src/app/ReaperClient.cpp | 80 ++++++++++++++++++++++++++++++++++ src/app/ReaperClient.h | 21 +++++++++ src/ui/RemoteControlDialog.cpp | 10 +++++ src/ui/RemoteControlDialog.h | 2 + tests/test_reaper_client.cpp | 17 ++++++++ 5 files changed, 130 insertions(+) diff --git a/src/app/ReaperClient.cpp b/src/app/ReaperClient.cpp index d312664..3775db2 100644 --- a/src/app/ReaperClient.cpp +++ b/src/app/ReaperClient.cpp @@ -1,17 +1,91 @@ #include "ReaperClient.h" #include +#include #include namespace OpenMix { +namespace { +// liblo callback (runs on the OSC server thread): extract a float and forward. +int hTransport(const char* path, const char* types, lo_arg** argv, int argc, lo_message, + void* user) { + float value = 1.0f; + if (argc > 0 && types) { + if (types[0] == 'f') + value = argv[0]->f; + else if (types[0] == 'i') + value = static_cast(argv[0]->i32); + } + static_cast(user)->deliverTransport(QString::fromUtf8(path), value); + return 0; +} +} // namespace + ReaperClient::ReaperClient(QObject* parent) : QObject(parent) { rebuildAddress(); } ReaperClient::~ReaperClient() { + stopListening(); if (m_address) lo_address_free(m_address); } +void ReaperClient::setAutoDetect(bool on) { + m_autoDetect = on; + if (on) + startListening(); + else + stopListening(); +} + +void ReaperClient::setListenPort(int port) { + if (port > 0) + m_listenPort = port; + if (m_autoDetect) + startListening(); // rebind +} + +void ReaperClient::startListening() { + stopListening(); + const QByteArray portStr = QByteArray::number(m_listenPort); + m_listener = lo_server_thread_new(portStr.constData(), nullptr); + if (!m_listener) + return; + lo_server_thread_add_method(m_listener, "/record", nullptr, hTransport, this); + lo_server_thread_add_method(m_listener, "/play", nullptr, hTransport, this); + lo_server_thread_add_method(m_listener, "/stop", nullptr, hTransport, this); + lo_server_thread_add_method(m_listener, "/pause", nullptr, hTransport, this); + lo_server_thread_start(m_listener); +} + +void ReaperClient::stopListening() { + if (m_listener) { + lo_server_thread_stop(m_listener); + lo_server_thread_free(m_listener); + m_listener = nullptr; + } +} + +void ReaperClient::deliverTransport(const QString& address, float value) { + // marshal from the OSC server thread onto this object's thread + QMetaObject::invokeMethod( + this, [this, address, value]() { onTransport(address, value); }, Qt::QueuedConnection); +} + +void ReaperClient::onTransport(const QString& address, float value) { + bool recording = m_recordMode; + if (address == QLatin1String("/record")) + recording = value != 0.0f; + else if (address == QLatin1String("/stop")) + recording = false; + // /play and /pause do not change the record state on their own + + if (recording != m_recordMode) { + m_recordMode = recording; + emit recordModeChanged(m_recordMode); + } +} + void ReaperClient::setTarget(const QString& host, int port) { m_host = host; m_port = port > 0 ? port : REAPER_DEFAULT_PORT; @@ -93,8 +167,12 @@ void ReaperClient::loadFromSettings() { m_enabled = settings.value("enabled", false).toBool(); m_recordMode = settings.value("recordMode", false).toBool(); m_preRollSeconds = settings.value("preRollSeconds", 0).toInt(); + m_listenPort = settings.value("listenPort", DEFAULT_LISTEN_PORT).toInt(); + m_autoDetect = settings.value("autoDetect", false).toBool(); settings.endGroup(); rebuildAddress(); + if (m_autoDetect) + startListening(); } void ReaperClient::saveToSettings() { @@ -105,6 +183,8 @@ void ReaperClient::saveToSettings() { settings.setValue("enabled", m_enabled); settings.setValue("recordMode", m_recordMode); settings.setValue("preRollSeconds", m_preRollSeconds); + settings.setValue("listenPort", m_listenPort); + settings.setValue("autoDetect", m_autoDetect); settings.endGroup(); } diff --git a/src/app/ReaperClient.h b/src/app/ReaperClient.h index ec87983..99978e6 100644 --- a/src/app/ReaperClient.h +++ b/src/app/ReaperClient.h @@ -17,6 +17,8 @@ class ReaperClient : public QObject { public: // REAPER's default OSC receive port. static constexpr int REAPER_DEFAULT_PORT = 8000; + // default local port we listen on for REAPER's transport feedback. + static constexpr int DEFAULT_LISTEN_PORT = 9000; // REAPER action id: "Markers: Insert marker at current position". static constexpr int ACTION_INSERT_MARKER = 40157; @@ -46,6 +48,19 @@ class ReaperClient : public QObject { void setPreRollSeconds(int seconds) { m_preRollSeconds = seconds < 0 ? 0 : seconds; } [[nodiscard]] int preRollSeconds() const { return m_preRollSeconds; } + // auto-detect record vs playback from REAPER's transport feedback OSC. + // When on, a local OSC server listens and /record drives the record mode. + void setAutoDetect(bool on); + [[nodiscard]] bool autoDetect() const { return m_autoDetect; } + void setListenPort(int port); + [[nodiscard]] int listenPort() const { return m_listenPort; } + + // apply a REAPER transport message (public for testing). /record sets the + // record mode; /stop clears it; /play and /pause leave it unchanged. + void onTransport(const QString& address, float value); + // called from the OSC listener thread; marshals onTransport to this thread. + void deliverTransport(const QString& address, float value); + void loadFromSettings(); void saveToSettings(); @@ -61,12 +76,15 @@ class ReaperClient : public QObject { signals: void markersChanged(); + void recordModeChanged(bool recording); signals: void sent(const QString& address); private: void rebuildAddress(); + void startListening(); + void stopListening(); void send(const QString& address); void sendString(const QString& address, const QString& value); void placeMarker(double cueNumber, const QString& name); @@ -78,6 +96,9 @@ class ReaperClient : public QObject { bool m_enabled = false; bool m_recordMode = false; int m_preRollSeconds = 0; + bool m_autoDetect = false; + int m_listenPort = DEFAULT_LISTEN_PORT; + lo_server_thread m_listener = nullptr; QList m_markers; // session markers, in fire order int m_nextMarker = 1; diff --git a/src/ui/RemoteControlDialog.cpp b/src/ui/RemoteControlDialog.cpp index 1657bb8..3e2a24e 100644 --- a/src/ui/RemoteControlDialog.cpp +++ b/src/ui/RemoteControlDialog.cpp @@ -90,11 +90,17 @@ void RemoteControlDialog::setupUi() { m_reaperPreRoll = new QSpinBox(reaperBox); m_reaperPreRoll->setRange(0, 60); m_reaperPreRoll->setSuffix(tr(" s")); + m_reaperAutoDetect = + new QCheckBox(tr("Auto-detect record/playback from REAPER"), reaperBox); + m_reaperListenPort = new QSpinBox(reaperBox); + m_reaperListenPort->setRange(1, 65535); reaperForm->addRow(m_reaperEnabled); reaperForm->addRow(tr("Host:"), m_reaperHost); reaperForm->addRow(tr("Port:"), m_reaperPort); reaperForm->addRow(m_reaperRecord); reaperForm->addRow(tr("Marker pre-roll:"), m_reaperPreRoll); + reaperForm->addRow(m_reaperAutoDetect); + reaperForm->addRow(tr("Listen port:"), m_reaperListenPort); layout->addWidget(reaperBox); auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); @@ -130,6 +136,8 @@ void RemoteControlDialog::loadValues() { m_reaperPort->setValue(reaper->port()); m_reaperRecord->setChecked(reaper->recordMode()); m_reaperPreRoll->setValue(reaper->preRollSeconds()); + m_reaperAutoDetect->setChecked(reaper->autoDetect()); + m_reaperListenPort->setValue(reaper->listenPort()); } } @@ -172,6 +180,8 @@ void RemoteControlDialog::applyValues() { m_reaperPort->value()); reaper->setRecordMode(m_reaperRecord->isChecked()); reaper->setPreRollSeconds(m_reaperPreRoll->value()); + reaper->setListenPort(m_reaperListenPort->value()); + reaper->setAutoDetect(m_reaperAutoDetect->isChecked()); reaper->saveToSettings(); } } diff --git a/src/ui/RemoteControlDialog.h b/src/ui/RemoteControlDialog.h index eb55795..898b81f 100644 --- a/src/ui/RemoteControlDialog.h +++ b/src/ui/RemoteControlDialog.h @@ -52,6 +52,8 @@ class RemoteControlDialog : public QDialog { QSpinBox* m_reaperPort = nullptr; QCheckBox* m_reaperRecord = nullptr; QSpinBox* m_reaperPreRoll = nullptr; + QCheckBox* m_reaperAutoDetect = nullptr; + QSpinBox* m_reaperListenPort = nullptr; }; } // namespace OpenMix diff --git a/tests/test_reaper_client.cpp b/tests/test_reaper_client.cpp index dcab48d..b287ada 100644 --- a/tests/test_reaper_client.cpp +++ b/tests/test_reaper_client.cpp @@ -68,6 +68,23 @@ class TestReaperClient : public QObject { QVERIFY(c.markers().isEmpty()); } + void transportRecordDrivesMode() { + ReaperClient c; + QVERIFY(!c.recordMode()); + + c.onTransport("/record", 1.0f); + QVERIFY(c.recordMode()); // recording -> drop markers + c.onTransport("/play", 1.0f); + QVERIFY(c.recordMode()); // playing while recording, unchanged + c.onTransport("/record", 0.0f); + QVERIFY(!c.recordMode()); // record off -> jump mode + + c.onTransport("/record", 1.0f); + QVERIFY(c.recordMode()); + c.onTransport("/stop", 1.0f); + QVERIFY(!c.recordMode()); // stop -> jump mode + } + void disabledSendsNothing() { ReaperClient c; c.setTarget("127.0.0.1", 8000); From 9002e4c69ca100ac6a7defbf7fc546282b3a7f24 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 12:51:28 -0400 Subject: [PATCH 41/92] feat: console snippet/scene name cache --- CMakeLists.txt | 2 ++ src/core/ConsoleNameCache.cpp | 56 +++++++++++++++++++++++++++++++ src/core/ConsoleNameCache.h | 42 +++++++++++++++++++++++ src/core/Show.cpp | 14 ++++++-- src/core/Show.h | 5 +++ tests/CMakeLists.txt | 16 +++++++++ tests/test_actor_profiles.cpp | 2 +- tests/test_console_name_cache.cpp | 43 ++++++++++++++++++++++++ tests/test_ensembles.cpp | 2 +- 9 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 src/core/ConsoleNameCache.cpp create mode 100644 src/core/ConsoleNameCache.h create mode 100644 tests/test_console_name_cache.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index d6a01d4..86b7bda 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -89,6 +89,7 @@ set(SOURCES src/core/Show.cpp src/core/SpareBackup.cpp src/core/FxLibrary.cpp + src/core/ConsoleNameCache.cpp src/core/PlaybackEngine.cpp src/core/UndoCommands.cpp src/core/CueValidator.cpp @@ -199,6 +200,7 @@ set(HEADERS src/core/Show.h src/core/SpareBackup.h src/core/FxLibrary.h + src/core/ConsoleNameCache.h src/core/PlaybackEngine.h src/core/UndoCommands.h src/core/CueValidator.h diff --git a/src/core/ConsoleNameCache.cpp b/src/core/ConsoleNameCache.cpp new file mode 100644 index 0000000..b9b3456 --- /dev/null +++ b/src/core/ConsoleNameCache.cpp @@ -0,0 +1,56 @@ +#include "ConsoleNameCache.h" +#include + +namespace OpenMix { + +ConsoleNameCache::ConsoleNameCache(QObject* parent) : QObject(parent) {} + +void ConsoleNameCache::setSnippetName(int index, const QString& name) { + if (m_snippets.value(index) == name && m_snippets.contains(index)) + return; + m_snippets[index] = name; + emit changed(); +} + +void ConsoleNameCache::setSceneName(int index, const QString& name) { + if (m_scenes.value(index) == name && m_scenes.contains(index)) + return; + m_scenes[index] = name; + emit changed(); +} + +void ConsoleNameCache::clear() { + if (isEmpty()) + return; + m_snippets.clear(); + m_scenes.clear(); + emit changed(); +} + +QJsonObject ConsoleNameCache::toJson() const { + QJsonObject snippets; + for (auto it = m_snippets.begin(); it != m_snippets.end(); ++it) + snippets[QString::number(it.key())] = it.value(); + QJsonObject scenes; + for (auto it = m_scenes.begin(); it != m_scenes.end(); ++it) + scenes[QString::number(it.key())] = it.value(); + + QJsonObject json; + json["snippets"] = snippets; + json["scenes"] = scenes; + return json; +} + +void ConsoleNameCache::loadFromJson(const QJsonObject& json) { + m_snippets.clear(); + m_scenes.clear(); + const QJsonObject snippets = json.value("snippets").toObject(); + for (auto it = snippets.begin(); it != snippets.end(); ++it) + m_snippets[it.key().toInt()] = it.value().toString(); + const QJsonObject scenes = json.value("scenes").toObject(); + for (auto it = scenes.begin(); it != scenes.end(); ++it) + m_scenes[it.key().toInt()] = it.value().toString(); + emit changed(); +} + +} // namespace OpenMix diff --git a/src/core/ConsoleNameCache.h b/src/core/ConsoleNameCache.h new file mode 100644 index 0000000..a6dfb93 --- /dev/null +++ b/src/core/ConsoleNameCache.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include +#include + +namespace OpenMix { + +// Cache of console-reported snippet and scene names, keyed by index. Populated +// from the console (name query) and persisted with the show so the UI can label +// snippets/scenes even while offline. Mirrors the console's snippet/scene name +// tables. +class ConsoleNameCache : public QObject { + Q_OBJECT + + public: + explicit ConsoleNameCache(QObject* parent = nullptr); + + void setSnippetName(int index, const QString& name); + [[nodiscard]] QString snippetName(int index) const { return m_snippets.value(index); } + [[nodiscard]] QMap snippetNames() const { return m_snippets; } + + void setSceneName(int index, const QString& name); + [[nodiscard]] QString sceneName(int index) const { return m_scenes.value(index); } + [[nodiscard]] QMap sceneNames() const { return m_scenes; } + + [[nodiscard]] bool isEmpty() const { return m_snippets.isEmpty() && m_scenes.isEmpty(); } + void clear(); + + QJsonObject toJson() const; + void loadFromJson(const QJsonObject& json); + + signals: + void changed(); + + private: + QMap m_snippets; // snippet index -> name + QMap m_scenes; // scene index -> name +}; + +} // namespace OpenMix diff --git a/src/core/Show.cpp b/src/core/Show.cpp index 69a9236..89a7979 100644 --- a/src/core/Show.cpp +++ b/src/core/Show.cpp @@ -24,9 +24,10 @@ MixerConfig MixerConfig::fromJson(const QJsonObject& json) { Show::Show(QObject* parent) : QObject(parent), m_cueList(this), m_dcaMapping(this), m_actorProfileLibrary(this), m_positionLibrary(this), m_ensembleLibrary(this), m_cueZero(this), m_spareBackup(this), - m_fxLibrary(this) { + m_fxLibrary(this), m_consoleNameCache(this) { connect(&m_spareBackup, &SpareBackup::changed, this, &Show::checkModifiedState); connect(&m_fxLibrary, &FxLibrary::changed, this, &Show::checkModifiedState); + connect(&m_consoleNameCache, &ConsoleNameCache::changed, this, &Show::checkModifiedState); connectCueListSignals(); connectDcaMappingSignals(); connectActorLibrarySignals(); @@ -145,13 +146,14 @@ void Show::newShow() { m_cueZero.clear(); m_spareBackup.setSpareChannel(-1); m_fxLibrary.clear(); + m_consoleNameCache.clear(); m_channelGangs.clear(); m_isDirty = false; } QJsonObject Show::toJson() const { QJsonObject json; - json["version"] = "1.6"; + json["version"] = "1.7"; json["name"] = m_name; json["author"] = m_author; json["designer"] = m_designer; @@ -170,6 +172,7 @@ QJsonObject Show::toJson() const { json["cueZero"] = m_cueZero.toJson(); json["spareBackup"] = m_spareBackup.toJson(); json["fx"] = m_fxLibrary.toJson(); + json["consoleNames"] = m_consoleNameCache.toJson(); QJsonArray gangArray; for (const auto& gang : m_channelGangs) { @@ -260,6 +263,13 @@ void Show::fromJson(const QJsonObject& json) { m_fxLibrary.clear(); } + // cached console snippet/scene names (added in show version 1.7) + if (json.contains("consoleNames")) { + m_consoleNameCache.loadFromJson(json["consoleNames"].toObject()); + } else { + m_consoleNameCache.clear(); + } + // ganged input-channel pairs m_channelGangs.clear(); if (json.contains("channelGangs")) { diff --git a/src/core/Show.h b/src/core/Show.h index 36c0f1a..55c6b3e 100644 --- a/src/core/Show.h +++ b/src/core/Show.h @@ -2,6 +2,7 @@ #include "ActorProfileLibrary.h" #include "CueList.h" +#include "ConsoleNameCache.h" #include "CueZero.h" #include "DCAMapping.h" #include "Ensemble.h" @@ -82,6 +83,9 @@ class Show : public QObject { [[nodiscard]] FxLibrary* fxLibrary() { return &m_fxLibrary; } [[nodiscard]] const FxLibrary* fxLibrary() const { return &m_fxLibrary; } + [[nodiscard]] ConsoleNameCache* consoleNameCache() { return &m_consoleNameCache; } + [[nodiscard]] const ConsoleNameCache* consoleNameCache() const { return &m_consoleNameCache; } + // recall the show's Cue Zero base state onto a connected mixer void applyCueZero(MixerProtocol* mixer) const { m_cueZero.apply(mixer); } @@ -150,6 +154,7 @@ class Show : public QObject { CueZero m_cueZero; SpareBackup m_spareBackup; FxLibrary m_fxLibrary; + ConsoleNameCache m_consoleNameCache; bool m_isDirty = false; }; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1b23583..727e843 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -32,6 +32,7 @@ add_executable(test_show ${CMAKE_SOURCE_DIR}/src/core/Show.cpp ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp ${CMAKE_SOURCE_DIR}/src/core/FxLibrary.cpp + ${CMAKE_SOURCE_DIR}/src/core/ConsoleNameCache.cpp ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp @@ -62,6 +63,7 @@ add_executable(test_actor_profiles ${CMAKE_SOURCE_DIR}/src/core/Show.cpp ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp ${CMAKE_SOURCE_DIR}/src/core/FxLibrary.cpp + ${CMAKE_SOURCE_DIR}/src/core/ConsoleNameCache.cpp ${CMAKE_SOURCE_DIR}/src/core/Ensemble.cpp ${CMAKE_SOURCE_DIR}/src/core/Position.cpp ${CMAKE_SOURCE_DIR}/src/core/CueZero.cpp @@ -300,6 +302,7 @@ add_executable(test_ensembles ${CMAKE_SOURCE_DIR}/src/core/Show.cpp ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp ${CMAKE_SOURCE_DIR}/src/core/FxLibrary.cpp + ${CMAKE_SOURCE_DIR}/src/core/ConsoleNameCache.cpp ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp @@ -339,6 +342,7 @@ add_executable(test_show_control ${CMAKE_SOURCE_DIR}/src/core/Show.cpp ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp ${CMAKE_SOURCE_DIR}/src/core/FxLibrary.cpp + ${CMAKE_SOURCE_DIR}/src/core/ConsoleNameCache.cpp ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp ${CMAKE_SOURCE_DIR}/src/core/CueValidator.cpp ${CMAKE_SOURCE_DIR}/src/core/PlaybackGuard.cpp @@ -370,6 +374,7 @@ add_executable(test_tmix_import ${CMAKE_SOURCE_DIR}/src/core/Show.cpp ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp ${CMAKE_SOURCE_DIR}/src/core/FxLibrary.cpp + ${CMAKE_SOURCE_DIR}/src/core/ConsoleNameCache.cpp ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp ${CMAKE_SOURCE_DIR}/src/core/ActorProfile.cpp ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp @@ -396,6 +401,7 @@ add_executable(test_channel_utilization ${CMAKE_SOURCE_DIR}/src/core/Show.cpp ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp ${CMAKE_SOURCE_DIR}/src/core/FxLibrary.cpp + ${CMAKE_SOURCE_DIR}/src/core/ConsoleNameCache.cpp ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp ${CMAKE_SOURCE_DIR}/src/core/ActorProfile.cpp ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp @@ -417,6 +423,7 @@ add_executable(test_spare_backup test_spare_backup.cpp ${CMAKE_SOURCE_DIR}/src/core/SpareBackup.cpp ${CMAKE_SOURCE_DIR}/src/core/FxLibrary.cpp + ${CMAKE_SOURCE_DIR}/src/core/ConsoleNameCache.cpp ) target_link_libraries(test_spare_backup PRIVATE Qt6::Core @@ -430,6 +437,7 @@ add_test(NAME SpareBackupTest COMMAND test_spare_backup) add_executable(test_fx_library test_fx_library.cpp ${CMAKE_SOURCE_DIR}/src/core/FxLibrary.cpp + ${CMAKE_SOURCE_DIR}/src/core/ConsoleNameCache.cpp ) target_link_libraries(test_fx_library PRIVATE Qt6::Core @@ -439,3 +447,11 @@ target_include_directories(test_fx_library PRIVATE ${CMAKE_SOURCE_DIR}/src ) add_test(NAME FxLibraryTest COMMAND test_fx_library) + +add_executable(test_console_name_cache + test_console_name_cache.cpp + ${CMAKE_SOURCE_DIR}/src/core/ConsoleNameCache.cpp +) +target_link_libraries(test_console_name_cache PRIVATE Qt6::Core Qt6::Test) +target_include_directories(test_console_name_cache PRIVATE ${CMAKE_SOURCE_DIR}/src) +add_test(NAME ConsoleNameCacheTest COMMAND test_console_name_cache) diff --git a/tests/test_actor_profiles.cpp b/tests/test_actor_profiles.cpp index dc97ae6..6800c3e 100644 --- a/tests/test_actor_profiles.cpp +++ b/tests/test_actor_profiles.cpp @@ -174,7 +174,7 @@ class TestActorProfiles : public QObject { QVERIFY(spy.count() >= 1); const QJsonObject json = show.toJson(); - QCOMPARE(json["version"].toString(), QString("1.6")); + QCOMPARE(json["version"].toString(), QString("1.7")); Show loaded; loaded.fromJson(json); diff --git a/tests/test_console_name_cache.cpp b/tests/test_console_name_cache.cpp new file mode 100644 index 0000000..d1eb6ea --- /dev/null +++ b/tests/test_console_name_cache.cpp @@ -0,0 +1,43 @@ +#include "core/ConsoleNameCache.h" +#include + +using namespace OpenMix; + +class TestConsoleNameCache : public QObject { + Q_OBJECT + + private slots: + void setAndGet() { + ConsoleNameCache c; + c.setSnippetName(3, "Reverb"); + c.setSceneName(1, "Act 1"); + QCOMPARE(c.snippetName(3), QString("Reverb")); + QCOMPARE(c.sceneName(1), QString("Act 1")); + QVERIFY(c.snippetName(99).isEmpty()); + QVERIFY(!c.isEmpty()); + } + + void roundTrip() { + ConsoleNameCache c; + c.setSnippetName(3, "Reverb"); + c.setSnippetName(4, "Delay"); + c.setSceneName(1, "Act 1"); + + ConsoleNameCache restored; + restored.loadFromJson(c.toJson()); + QCOMPARE(restored.snippetName(3), QString("Reverb")); + QCOMPARE(restored.snippetName(4), QString("Delay")); + QCOMPARE(restored.sceneName(1), QString("Act 1")); + } + + void clearEmpties() { + ConsoleNameCache c; + c.setSnippetName(1, "x"); + QVERIFY(!c.isEmpty()); + c.clear(); + QVERIFY(c.isEmpty()); + } +}; + +QTEST_MAIN(TestConsoleNameCache) +#include "test_console_name_cache.moc" diff --git a/tests/test_ensembles.cpp b/tests/test_ensembles.cpp index 5edc196..b573843 100644 --- a/tests/test_ensembles.cpp +++ b/tests/test_ensembles.cpp @@ -113,7 +113,7 @@ class TestEnsembles : public QObject { QVERIFY(spy.count() >= 1); const QJsonObject json = show.toJson(); - QCOMPARE(json["version"].toString(), QString("1.6")); + QCOMPARE(json["version"].toString(), QString("1.7")); Show loaded; loaded.fromJson(json); From 2974b2d8ebbabe818ff1d90fe3ede2f7edbba68a Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 12:55:16 -0400 Subject: [PATCH 42/92] feat: populate console name cache from X32 and show snippet names --- src/app/Application.cpp | 16 +++++++++++++++- src/protocol/MixerProtocol.h | 6 ++++++ src/protocol/behringer/X32Protocol.cpp | 19 +++++++++++++++++++ src/protocol/behringer/X32Protocol.h | 1 + src/ui/CueEditor.cpp | 14 ++++++++++++-- 5 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/app/Application.cpp b/src/app/Application.cpp index 3585899..a9155b7 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -284,9 +284,23 @@ void Application::setupMixerConnection(const QString& type, const QString& host, m_connectionLogBridge->attachToMixer(m_mixer); m_appLogger->logConnectionAttempt(type, host, port); - connect(m_mixer, &MixerProtocol::connected, this, [this]() { emit mixerConnected(); }); + connect(m_mixer, &MixerProtocol::connected, this, [this]() { + emit mixerConnected(); + m_mixer->requestConsoleNames(100); // populate the snippet/scene name cache + }); connect(m_mixer, &MixerProtocol::disconnected, this, [this]() { emit mixerDisconnected(); }); + // console-reported snippet/scene names -> show name cache + connect(m_mixer, &MixerProtocol::consoleNameReceived, this, + [this](bool isScene, int index, const QString& name) { + if (name.isEmpty()) + return; + if (isScene) + m_show->consoleNameCache()->setSceneName(index, name); + else + m_show->consoleNameCache()->setSnippetName(index, name); + }); + // live console metering -> channel silence/clip monitor connect(m_mixer, &MixerProtocol::channelMeter, m_channelMonitor, [this](int channel, float level) { m_channelMonitor->onLevel(channel, level); }); diff --git a/src/protocol/MixerProtocol.h b/src/protocol/MixerProtocol.h index 81b374c..d8f1d00 100644 --- a/src/protocol/MixerProtocol.h +++ b/src/protocol/MixerProtocol.h @@ -71,6 +71,10 @@ class MixerProtocol : public QObject { return std::nullopt; } + // query the console for its snippet/scene names (indices 1..count). Default + // no-op; drivers that can read names override and emit consoleNameReceived. + virtual void requestConsoleNames(int /*count*/) {} + virtual void refresh() = 0; virtual int latencyMs() const = 0; virtual const MixerCapabilities& capabilities() const; @@ -89,6 +93,8 @@ class MixerProtocol : public QObject { void connectionLost(); 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); void requestTimeout(const QString& path); // live input-channel meter level (channel 1-based, level 0..1 linear). Emitted diff --git a/src/protocol/behringer/X32Protocol.cpp b/src/protocol/behringer/X32Protocol.cpp index 1f270a3..9cee95b 100644 --- a/src/protocol/behringer/X32Protocol.cpp +++ b/src/protocol/behringer/X32Protocol.cpp @@ -343,6 +343,16 @@ void X32Protocol::refresh() { } } +void X32Protocol::requestConsoleNames(int count) { + if (m_connectionState != ConnectionState::Connected) + return; + for (int i = 1; i <= count; ++i) { + const QString idx = QStringLiteral("%1").arg(i, 3, 10, QChar('0')); + requestParameter(QStringLiteral("/-show/showfile/snippet/%1/name").arg(idx)); + requestParameter(QStringLiteral("/-show/showfile/scene/%1/name").arg(idx)); + } +} + void X32Protocol::onTransportConnected() { // transport connected, but we still wait for /xinfo response } @@ -472,6 +482,15 @@ void X32Protocol::processResponse(const QString& path, const QVariant& value) { return; } + // console snippet/scene name responses -> name cache + static const QRegularExpression nameRe( + QStringLiteral("^/-show/showfile/(scene|snippet)/(\\d+)/name$")); + const QRegularExpressionMatch nameMatch = nameRe.match(path); + if (nameMatch.hasMatch()) { + emit consoleNameReceived(nameMatch.captured(1) == QLatin1String("scene"), + nameMatch.captured(2).toInt(), value.toString()); + } + if (m_pendingRequests.contains(path)) { X32PendingRequest req = m_pendingRequests.take(path); qint64 roundTrip = now - req.sentTime; diff --git a/src/protocol/behringer/X32Protocol.h b/src/protocol/behringer/X32Protocol.h index 58e001d..d388746 100644 --- a/src/protocol/behringer/X32Protocol.h +++ b/src/protocol/behringer/X32Protocol.h @@ -71,6 +71,7 @@ class X32Protocol : public MixerProtocol { // keep-alive void refresh() override; + void requestConsoleNames(int count) override; // latency monitoring [[nodiscard]] int latencyMs() const override { return m_latencyMs; } diff --git a/src/ui/CueEditor.cpp b/src/ui/CueEditor.cpp index 3175640..1bf07f9 100644 --- a/src/ui/CueEditor.cpp +++ b/src/ui/CueEditor.cpp @@ -477,11 +477,21 @@ void CueEditor::updateFromCue() { m_colorEdit->setText(cue->color()); m_skipCheck->setChecked(cue->skip()); - // console snippets + // console snippets, with cached names surfaced in the tooltip + const ConsoleNameCache* names = + m_app && m_app->show() ? m_app->show()->consoleNameCache() : nullptr; QStringList snippetStrs; - for (int snippet : cue->snippets()) + QStringList snippetNamed; + for (int snippet : cue->snippets()) { snippetStrs << QString::number(snippet); + const QString name = names ? names->snippetName(snippet) : QString(); + snippetNamed << (name.isEmpty() ? QString::number(snippet) + : 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(", "))); // per-FX-unit mutes updateFxMutesUI(); From 249b7c32036e5275b4c431e007d4b588c1efb593 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 13:02:31 -0400 Subject: [PATCH 43/92] feat: record faders mode writes live moves into the current cue --- src/app/Application.cpp | 19 +++++++++++++++++++ src/app/Application.h | 8 ++++++++ src/protocol/MixerProtocol.h | 2 ++ src/protocol/behringer/X32Protocol.cpp | 7 +++++++ src/ui/MainWindow.cpp | 8 ++++++++ src/ui/MainWindow.h | 1 + 6 files changed, 45 insertions(+) diff --git a/src/app/Application.cpp b/src/app/Application.cpp index a9155b7..21c8ff2 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -305,6 +305,18 @@ void Application::setupMixerConnection(const QString& type, const QString& host, connect(m_mixer, &MixerProtocol::channelMeter, m_channelMonitor, [this](int channel, float level) { m_channelMonitor->onLevel(channel, level); }); + // record-faders: live console fader moves write into the current cue + connect(m_mixer, &MixerProtocol::channelFaderChanged, this, [this](int channel, double level) { + if (!m_recordFadersActive || !m_show->cueList()) + return; + const int idx = m_playbackEngine->currentCueIndex(); + if (idx < 0 || idx >= m_show->cueList()->count()) + return; + Cue cue = m_show->cueList()->at(idx); + cue.setChannelLevel(channel, level); + m_show->cueList()->updateCue(idx, cue); + }); + // scribble strips push actor names/colors to this console m_scribbleController->setMixer(m_mixer); @@ -379,6 +391,13 @@ void Application::startupScan() { m_discoveryService->startScan(3000); } +void Application::setRecordFadersActive(bool active) { + if (m_recordFadersActive == active) + return; + m_recordFadersActive = active; + emit recordFadersActiveChanged(active); +} + void Application::setMainWindow(MainWindow* window) { m_mainWindow = window; } MainWindow* Application::mainWindow() { return m_mainWindow; } diff --git a/src/app/Application.h b/src/app/Application.h index 48531bc..e0a2376 100644 --- a/src/app/Application.h +++ b/src/app/Application.h @@ -90,6 +90,11 @@ class Application : public QObject { void setMainWindow(MainWindow* window); [[nodiscard]] MainWindow* mainWindow(); + // record-faders: while active, console fader moves are written live into the + // current cue's channel levels. + void setRecordFadersActive(bool active); + [[nodiscard]] bool recordFadersActive() const { return m_recordFadersActive; } + // initialization void initialize(); @@ -99,6 +104,7 @@ class Application : public QObject { signals: void mixerConnected(); void mixerDisconnected(); + void recordFadersActiveChanged(bool active); private: void setupMixerConnection(const QString& type, const QString& host, int port); @@ -143,6 +149,8 @@ class Application : public QObject { QLabClient* m_qLabClient; ReaperClient* m_reaperClient; + bool m_recordFadersActive = false; + // timecode-triggered cues + channel silence/clip monitoring TimecodeTriggerList* m_timecodeTriggers; ChannelMonitor* m_channelMonitor; diff --git a/src/protocol/MixerProtocol.h b/src/protocol/MixerProtocol.h index d8f1d00..84711a9 100644 --- a/src/protocol/MixerProtocol.h +++ b/src/protocol/MixerProtocol.h @@ -95,6 +95,8 @@ 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). + void channelFaderChanged(int channel, double level); void requestTimeout(const QString& path); // live input-channel meter level (channel 1-based, level 0..1 linear). Emitted diff --git a/src/protocol/behringer/X32Protocol.cpp b/src/protocol/behringer/X32Protocol.cpp index 9cee95b..bcbbc4a 100644 --- a/src/protocol/behringer/X32Protocol.cpp +++ b/src/protocol/behringer/X32Protocol.cpp @@ -491,6 +491,13 @@ void X32Protocol::processResponse(const QString& path, const QVariant& value) { nameMatch.captured(2).toInt(), value.toString()); } + // input-channel fader move -> semantic signal (used by record-faders) + 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()); + } + if (m_pendingRequests.contains(path)) { X32PendingRequest req = m_pendingRequests.take(path); qint64 roundTrip = now - req.sentTime; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index df07e04..298696f 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -263,6 +263,13 @@ void MainWindow::createActions() { m_spareBackupAction->setToolTip(tr("Allocate the spare-backup channel and switch to it")); connect(m_spareBackupAction, &QAction::triggered, this, &MainWindow::showAllocateSpareDialog); + m_recordFadersAction = new QAction(tr("&Record Faders"), this); + m_recordFadersAction->setCheckable(true); + m_recordFadersAction->setToolTip(tr("Write live console fader moves into the current cue")); + connect(m_recordFadersAction, &QAction::toggled, m_app, &Application::setRecordFadersActive); + connect(m_app, &Application::recordFadersActiveChanged, m_recordFadersAction, + &QAction::setChecked); + m_showDCAMappingAction = new QAction(Icons::sliders(), tr("&DCA Mapping"), this); m_showDCAMappingAction->setCheckable(true); m_showDCAMappingAction->setChecked(false); @@ -489,6 +496,7 @@ void MainWindow::createMenus() { m_playbackMenu->addAction(m_panicRestoreAction); m_playbackMenu->addSeparator(); m_playbackMenu->addAction(m_spareBackupAction); + m_playbackMenu->addAction(m_recordFadersAction); m_viewMenu = menuBar()->addMenu(tr("&View")); m_viewMenu->addAction(m_showDCAMappingAction); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 2f9640e..f79305b 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -207,6 +207,7 @@ class MainWindow : public QMainWindow { QAction* m_panicAction; QAction* m_panicRestoreAction; QAction* m_spareBackupAction; + QAction* m_recordFadersAction; // view actions (for menu checkable items) QAction* m_showConnectionAction; From 04c6935e56ed4638888ef5d3dea3c78dde8468dd Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 13:17:42 -0400 Subject: [PATCH 44/92] feat: OSC remote stopall and pauseresumeall verbs --- src/app/OscRemoteServer.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/OscRemoteServer.cpp b/src/app/OscRemoteServer.cpp index f0f5ad6..1c062af 100644 --- a/src/app/OscRemoteServer.cpp +++ b/src/app/OscRemoteServer.cpp @@ -102,6 +102,8 @@ void OscRemoteServer::registerMethods() { lo_server_thread_add_method(m_server, "/stop", nullptr, hStop, this); lo_server_thread_add_method(m_server, "/cue/stop", nullptr, hStop, this); lo_server_thread_add_method(m_server, "/ctrl/stop", nullptr, hStop, this); + lo_server_thread_add_method(m_server, "/ctrl/stopall", nullptr, hStop, this); + lo_server_thread_add_method(m_server, "/ctrl/pauseresumeall", nullptr, hStop, this); lo_server_thread_add_method(m_server, "/next", nullptr, hNext, this); lo_server_thread_add_method(m_server, "/prev", nullptr, hPrev, this); lo_server_thread_add_method(m_server, "/previous", nullptr, hPrev, this); From fd83e8f8570bdd665f93f5aff9ac63f6e1c55b6e Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 15:03:27 -0400 Subject: [PATCH 45/92] feat: Cue Player (cpsound) outbound OSC integration --- CMakeLists.txt | 2 ++ src/app/Application.cpp | 9 +++++ src/app/Application.h | 3 ++ src/app/CuePlayerClient.cpp | 60 +++++++++++++++++++++++++++++++++ src/app/CuePlayerClient.h | 48 ++++++++++++++++++++++++++ src/ui/RemoteControlDialog.cpp | 29 ++++++++++++++++ src/ui/RemoteControlDialog.h | 4 +++ tests/CMakeLists.txt | 8 +++++ tests/test_cueplayer_client.cpp | 35 +++++++++++++++++++ 9 files changed, 198 insertions(+) create mode 100644 src/app/CuePlayerClient.cpp create mode 100644 src/app/CuePlayerClient.h create mode 100644 tests/test_cueplayer_client.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 86b7bda..ba67e07 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -84,6 +84,7 @@ set(SOURCES src/app/OscRemoteServer.cpp src/app/QLabClient.cpp src/app/ReaperClient.cpp + src/app/CuePlayerClient.cpp src/core/Cue.cpp src/core/CueList.cpp src/core/Show.cpp @@ -195,6 +196,7 @@ set(HEADERS src/app/OscRemoteServer.h src/app/QLabClient.h src/app/ReaperClient.h + src/app/CuePlayerClient.h src/core/Cue.h src/core/CueList.h src/core/Show.h diff --git a/src/app/Application.cpp b/src/app/Application.cpp index 21c8ff2..a15706a 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -1,5 +1,6 @@ #include "Application.h" #include "OscRemoteServer.h" +#include "CuePlayerClient.h" #include "QLabClient.h" #include "ReaperClient.h" #include "ui/MainWindow.h" @@ -71,6 +72,7 @@ Application::Application(QObject* parent) : QObject(parent) { // outbound QLab / DAW remote m_qLabClient = new QLabClient(this); m_reaperClient = new ReaperClient(this); + m_cuePlayerClient = new CuePlayerClient(this); // timecode triggers + channel monitor m_timecodeTriggers = new TimecodeTriggerList(this); @@ -208,6 +210,13 @@ void Application::initialize() { m_qLabClient->triggerCue(cue.qLabCue()); }); + // Cue Player (cpsound): advance the external player on each cue fire + m_cuePlayerClient->loadFromSettings(); + connect(m_playbackEngine, &PlaybackEngine::cueExecuted, this, [this](int index) { + if (m_cuePlayerClient->isEnabled() && index >= 0) + m_cuePlayerClient->play(); + }); + // REAPER virtual sound check: drop/jump a marker on each cue fire m_reaperClient->loadFromSettings(); connect(m_playbackEngine, &PlaybackEngine::cueExecuted, this, [this](int index) { diff --git a/src/app/Application.h b/src/app/Application.h index e0a2376..6918afd 100644 --- a/src/app/Application.h +++ b/src/app/Application.h @@ -24,6 +24,7 @@ class ConnectionLogBridge; class OscRemoteServer; class QLabClient; class ReaperClient; +class CuePlayerClient; class TimecodeTriggerList; class ChannelMonitor; class ScribbleController; @@ -73,6 +74,7 @@ class Application : public QObject { // outbound QLab / DAW remote [[nodiscard]] QLabClient* qLabClient() { return m_qLabClient; } [[nodiscard]] ReaperClient* reaperClient() { return m_reaperClient; } + [[nodiscard]] CuePlayerClient* cuePlayerClient() { return m_cuePlayerClient; } // timecode triggers + channel monitor [[nodiscard]] TimecodeTriggerList* timecodeTriggers() { return m_timecodeTriggers; } @@ -148,6 +150,7 @@ class Application : public QObject { // outbound QLab / DAW remote QLabClient* m_qLabClient; ReaperClient* m_reaperClient; + CuePlayerClient* m_cuePlayerClient; bool m_recordFadersActive = false; diff --git a/src/app/CuePlayerClient.cpp b/src/app/CuePlayerClient.cpp new file mode 100644 index 0000000..88e0e6c --- /dev/null +++ b/src/app/CuePlayerClient.cpp @@ -0,0 +1,60 @@ +#include "CuePlayerClient.h" + +#include +#include + +namespace OpenMix { + +CuePlayerClient::CuePlayerClient(QObject* parent) : QObject(parent) { rebuildAddress(); } + +CuePlayerClient::~CuePlayerClient() { + if (m_address) + lo_address_free(m_address); +} + +void CuePlayerClient::setTarget(const QString& host, int port) { + m_host = host; + m_port = port > 0 ? port : CUEPLAYER_DEFAULT_PORT; + rebuildAddress(); +} + +void CuePlayerClient::rebuildAddress() { + if (m_address) { + lo_address_free(m_address); + m_address = nullptr; + } + m_address = + lo_address_new(m_host.toUtf8().constData(), QByteArray::number(m_port).constData()); +} + +void CuePlayerClient::send(const QString& address) { + if (!m_enabled || !m_address) + return; + lo_send(m_address, address.toUtf8().constData(), ""); + emit sent(address); +} + +void CuePlayerClient::play() { send(QStringLiteral("/cpsound/play")); } + +void CuePlayerClient::stop() { send(QStringLiteral("/cpsound/stop")); } + +void CuePlayerClient::loadFromSettings() { + QSettings settings; + settings.beginGroup("CuePlayer"); + m_host = settings.value("host", m_host).toString(); + m_port = settings.value("port", m_port).toInt(); + m_enabled = settings.value("enabled", false).toBool(); + settings.endGroup(); + rebuildAddress(); +} + +void CuePlayerClient::saveToSettings() { + QSettings settings; + settings.beginGroup("CuePlayer"); + settings.setValue("host", m_host); + settings.setValue("port", m_port); + settings.setValue("enabled", m_enabled); + settings.endGroup(); +} + +} // namespace OpenMix diff --git a/src/app/CuePlayerClient.h b/src/app/CuePlayerClient.h new file mode 100644 index 0000000..7315bf3 --- /dev/null +++ b/src/app/CuePlayerClient.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include + +namespace OpenMix { + +// Outbound link to a Cue Player (cpsound) playback device: on a linked cue's +// GO, sends /cpsound/play so the external player advances in step. Matches the +// cpsound OSC dialect (default port 50550). +class CuePlayerClient : public QObject { + Q_OBJECT + + public: + static constexpr int CUEPLAYER_DEFAULT_PORT = 50550; + + explicit CuePlayerClient(QObject* parent = nullptr); + ~CuePlayerClient() override; + + void setTarget(const QString& host, int port); + [[nodiscard]] QString host() const { return m_host; } + [[nodiscard]] int port() const { return m_port; } + + void setEnabled(bool enabled) { m_enabled = enabled; } + [[nodiscard]] bool isEnabled() const { return m_enabled; } + + void loadFromSettings(); + void saveToSettings(); + + public slots: + void play(); // /cpsound/play + void stop(); // /cpsound/stop + + signals: + void sent(const QString& address); + + private: + void rebuildAddress(); + void send(const QString& address); + + lo_address m_address = nullptr; + QString m_host = QStringLiteral("127.0.0.1"); + int m_port = CUEPLAYER_DEFAULT_PORT; + bool m_enabled = false; +}; + +} // namespace OpenMix diff --git a/src/ui/RemoteControlDialog.cpp b/src/ui/RemoteControlDialog.cpp index 3e2a24e..8f2f18d 100644 --- a/src/ui/RemoteControlDialog.cpp +++ b/src/ui/RemoteControlDialog.cpp @@ -1,6 +1,7 @@ #include "RemoteControlDialog.h" #include "app/Application.h" #include "app/OscRemoteServer.h" +#include "app/CuePlayerClient.h" #include "app/QLabClient.h" #include "app/ReaperClient.h" #include "midi/MidiInputManager.h" @@ -103,6 +104,19 @@ void RemoteControlDialog::setupUi() { reaperForm->addRow(tr("Listen port:"), m_reaperListenPort); layout->addWidget(reaperBox); + // --- Cue Player (cpsound) --------------------------------------------- + auto* cpBox = new QGroupBox(tr("Cue Player (cpsound)"), this); + auto* cpForm = new QFormLayout(cpBox); + m_cuePlayerEnabled = new QCheckBox(tr("Advance Cue Player on GO"), cpBox); + m_cuePlayerHost = new QLineEdit(cpBox); + m_cuePlayerHost->setPlaceholderText(tr("127.0.0.1")); + m_cuePlayerPort = new QSpinBox(cpBox); + m_cuePlayerPort->setRange(1, 65535); + cpForm->addRow(m_cuePlayerEnabled); + cpForm->addRow(tr("Host:"), m_cuePlayerHost); + cpForm->addRow(tr("Port:"), m_cuePlayerPort); + layout->addWidget(cpBox); + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); connect(buttons, &QDialogButtonBox::accepted, this, &RemoteControlDialog::accept); connect(buttons, &QDialogButtonBox::rejected, this, &RemoteControlDialog::reject); @@ -139,6 +153,12 @@ void RemoteControlDialog::loadValues() { m_reaperAutoDetect->setChecked(reaper->autoDetect()); m_reaperListenPort->setValue(reaper->listenPort()); } + + if (CuePlayerClient* cp = m_app->cuePlayerClient()) { + m_cuePlayerEnabled->setChecked(cp->isEnabled()); + m_cuePlayerHost->setText(cp->host()); + m_cuePlayerPort->setValue(cp->port()); + } } void RemoteControlDialog::applyValues() { @@ -184,6 +204,15 @@ void RemoteControlDialog::applyValues() { reaper->setAutoDetect(m_reaperAutoDetect->isChecked()); reaper->saveToSettings(); } + + if (CuePlayerClient* cp = m_app->cuePlayerClient()) { + cp->setEnabled(m_cuePlayerEnabled->isChecked()); + cp->setTarget(m_cuePlayerHost->text().trimmed().isEmpty() + ? QStringLiteral("127.0.0.1") + : m_cuePlayerHost->text().trimmed(), + m_cuePlayerPort->value()); + cp->saveToSettings(); + } } void RemoteControlDialog::accept() { diff --git a/src/ui/RemoteControlDialog.h b/src/ui/RemoteControlDialog.h index 898b81f..a17abf4 100644 --- a/src/ui/RemoteControlDialog.h +++ b/src/ui/RemoteControlDialog.h @@ -54,6 +54,10 @@ class RemoteControlDialog : public QDialog { QSpinBox* m_reaperPreRoll = nullptr; QCheckBox* m_reaperAutoDetect = nullptr; QSpinBox* m_reaperListenPort = nullptr; + + QCheckBox* m_cuePlayerEnabled = nullptr; + QLineEdit* m_cuePlayerHost = nullptr; + QSpinBox* m_cuePlayerPort = nullptr; }; } // namespace OpenMix diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 727e843..78d824b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -168,6 +168,14 @@ target_link_libraries(test_reaper_client PRIVATE Qt6::Core Qt6::Test PkgConfig:: target_include_directories(test_reaper_client PRIVATE ${CMAKE_SOURCE_DIR}/src) add_test(NAME ReaperClientTest COMMAND test_reaper_client) +add_executable(test_cueplayer_client + test_cueplayer_client.cpp + ${CMAKE_SOURCE_DIR}/src/app/CuePlayerClient.cpp +) +target_link_libraries(test_cueplayer_client PRIVATE Qt6::Core Qt6::Test PkgConfig::LIBLO) +target_include_directories(test_cueplayer_client PRIVATE ${CMAKE_SOURCE_DIR}/src) +add_test(NAME CuePlayerClientTest COMMAND test_cueplayer_client) + add_executable(test_undo_commands test_undo_commands.cpp ${CMAKE_SOURCE_DIR}/src/core/UndoCommands.cpp diff --git a/tests/test_cueplayer_client.cpp b/tests/test_cueplayer_client.cpp new file mode 100644 index 0000000..6333180 --- /dev/null +++ b/tests/test_cueplayer_client.cpp @@ -0,0 +1,35 @@ +#include "app/CuePlayerClient.h" +#include +#include + +using namespace OpenMix; + +class TestCuePlayerClient : public QObject { + Q_OBJECT + + private slots: + void playAndStopSendCpsound() { + CuePlayerClient c; + c.setTarget("127.0.0.1", 50550); + c.setEnabled(true); + + QSignalSpy sent(&c, &CuePlayerClient::sent); + c.play(); + c.stop(); + + QCOMPARE(sent.count(), 2); + QCOMPARE(sent.at(0).at(0).toString(), QString("/cpsound/play")); + QCOMPARE(sent.at(1).at(0).toString(), QString("/cpsound/stop")); + } + + void disabledSendsNothing() { + CuePlayerClient c; + c.setTarget("127.0.0.1", 50550); + QSignalSpy sent(&c, &CuePlayerClient::sent); + c.play(); + QCOMPARE(sent.count(), 0); + } +}; + +QTEST_MAIN(TestCuePlayerClient) +#include "test_cueplayer_client.moc" From 663832e4b85e918870007ba4bfb27a6b8508e387 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 15:43:09 -0400 Subject: [PATCH 46/92] feat: DiGiCo SD-series console driver --- CMakeLists.txt | 4 + src/protocol/MixerCapabilities.cpp | 102 ++++++++ src/protocol/MixerCapabilities.h | 7 +- src/protocol/ProtocolFactory.cpp | 11 + src/protocol/digico/DiGiCoProtocol.cpp | 322 +++++++++++++++++++++++++ src/protocol/digico/DiGiCoProtocol.h | 103 ++++++++ tests/CMakeLists.txt | 23 ++ tests/test_digico_protocol.cpp | 79 ++++++ 8 files changed, 650 insertions(+), 1 deletion(-) create mode 100644 src/protocol/digico/DiGiCoProtocol.cpp create mode 100644 src/protocol/digico/DiGiCoProtocol.h create mode 100644 tests/test_digico_protocol.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ba67e07..196ebc7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -168,6 +168,8 @@ set(SOURCES src/protocol/yamaha/YamahaCLProtocol.cpp src/protocol/yamaha/YamahaDM7Protocol.cpp + src/protocol/digico/DiGiCoProtocol.cpp + src/protocol/discovery/DiscoveredConsole.cpp src/protocol/discovery/ConsoleDiscoveryService.cpp src/protocol/discovery/probes/BehringerX32ProbeStrategy.cpp @@ -282,6 +284,8 @@ set(HEADERS src/protocol/yamaha/YamahaCLProtocol.h src/protocol/yamaha/YamahaDM7Protocol.h + src/protocol/digico/DiGiCoProtocol.h + src/protocol/discovery/DiscoveredConsole.h src/protocol/discovery/OscProbeStrategy.h src/protocol/discovery/ConsoleDiscoveryService.h diff --git a/src/protocol/MixerCapabilities.cpp b/src/protocol/MixerCapabilities.cpp index 8e35769..0c29e7d 100644 --- a/src/protocol/MixerCapabilities.cpp +++ b/src/protocol/MixerCapabilities.cpp @@ -382,6 +382,83 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { caps.supportsEffectSends = true; break; + // DiGiCo SD series (TLV binary over TCP 51321, reverse-engineered) + case ConsoleType::SD7: + caps.manufacturer = Manufacturer::DiGiCo; + caps.protocol = ProtocolType::BinaryTcp; + caps.displayName = "DiGiCo SD7"; + caps.protocolId = "sd7"; + caps.defaultPort = 51321; + caps.dcaCount = 24; + caps.inputChannels = 128; + caps.mixBuses = 48; + caps.matrixOutputs = 36; + caps.scenes = 1000; + caps.maxDCANameLength = 8; + caps.eqBandsPerChannel = 4; + caps.supportsChannelEQ = true; + caps.eqBandTypes = {"LShv", "PEQ", "PEQ", "HShv"}; + caps.effectSendBuses = 16; + caps.supportsEffectSends = true; + break; + + case ConsoleType::SD9: + caps.manufacturer = Manufacturer::DiGiCo; + caps.protocol = ProtocolType::BinaryTcp; + caps.displayName = "DiGiCo SD9"; + caps.protocolId = "sd9"; + caps.defaultPort = 51321; + caps.dcaCount = 12; + caps.inputChannels = 96; + caps.mixBuses = 48; + caps.matrixOutputs = 24; + caps.scenes = 1000; + caps.maxDCANameLength = 8; + caps.eqBandsPerChannel = 4; + caps.supportsChannelEQ = true; + caps.eqBandTypes = {"LShv", "PEQ", "PEQ", "HShv"}; + caps.effectSendBuses = 16; + caps.supportsEffectSends = true; + break; + + case ConsoleType::SD11: + caps.manufacturer = Manufacturer::DiGiCo; + caps.protocol = ProtocolType::BinaryTcp; + caps.displayName = "DiGiCo SD11"; + caps.protocolId = "sd11"; + caps.defaultPort = 51321; + caps.dcaCount = 8; + caps.inputChannels = 32; + caps.mixBuses = 16; + caps.matrixOutputs = 8; + caps.scenes = 1000; + caps.maxDCANameLength = 8; + caps.eqBandsPerChannel = 4; + caps.supportsChannelEQ = true; + caps.eqBandTypes = {"LShv", "PEQ", "PEQ", "HShv"}; + caps.effectSendBuses = 8; + caps.supportsEffectSends = true; + break; + + case ConsoleType::SD12: + caps.manufacturer = Manufacturer::DiGiCo; + caps.protocol = ProtocolType::BinaryTcp; + caps.displayName = "DiGiCo SD12"; + caps.protocolId = "sd12"; + caps.defaultPort = 51321; + caps.dcaCount = 12; + caps.inputChannels = 72; + caps.mixBuses = 36; + caps.matrixOutputs = 12; + caps.scenes = 1000; + caps.maxDCANameLength = 8; + caps.eqBandsPerChannel = 4; + caps.supportsChannelEQ = true; + caps.eqBandTypes = {"LShv", "PEQ", "PEQ", "HShv"}; + caps.effectSendBuses = 16; + caps.supportsEffectSends = true; + break; + // Loopback case ConsoleType::Loopback: caps.manufacturer = Manufacturer::Unknown; @@ -469,6 +546,16 @@ MixerCapabilities MixerCapabilities::forProtocolId(const QString& protocolId) { if (id == "dm7") return forConsole(ConsoleType::DM7); + // DiGiCo SD + if (id == "sd7") + return forConsole(ConsoleType::SD7); + if (id == "sd9") + return forConsole(ConsoleType::SD9); + if (id == "sd11") + return forConsole(ConsoleType::SD11); + if (id == "sd12" || id == "sd") + return forConsole(ConsoleType::SD12); + // Loopback if (id == "loopback" || id == "test") return forConsole(ConsoleType::Loopback); @@ -504,6 +591,12 @@ QVector MixerCapabilities::allSupported() { all.append(forConsole(ConsoleType::CL5)); all.append(forConsole(ConsoleType::DM7)); + // DiGiCo + all.append(forConsole(ConsoleType::SD7)); + all.append(forConsole(ConsoleType::SD9)); + all.append(forConsole(ConsoleType::SD11)); + all.append(forConsole(ConsoleType::SD12)); + return all; } @@ -555,6 +648,13 @@ bool MixerCapabilities::isSupported() const { case ConsoleType::DM7: return true; + // DiGiCo SD (reverse-engineered, hardware-unvalidated) + case ConsoleType::SD7: + case ConsoleType::SD9: + case ConsoleType::SD11: + case ConsoleType::SD12: + return true; + // Behringer Wing case ConsoleType::Wing: return true; @@ -574,6 +674,8 @@ QString MixerCapabilities::manufacturerName() const { return "Allen & Heath"; case Manufacturer::Yamaha: return "Yamaha"; + case Manufacturer::DiGiCo: + return "DiGiCo"; default: return "Unknown"; } diff --git a/src/protocol/MixerCapabilities.h b/src/protocol/MixerCapabilities.h index b96fb84..b9de159 100644 --- a/src/protocol/MixerCapabilities.h +++ b/src/protocol/MixerCapabilities.h @@ -29,6 +29,11 @@ enum class ConsoleType { CL5, // CL5 DM7, // DM7 / DM7 Compact + SD7, // DiGiCo SD7 + SD9, // DiGiCo SD9 + SD11, // DiGiCo SD11 + SD12, // DiGiCo SD12 + Loopback, // loopback for testing Unknown @@ -44,7 +49,7 @@ enum class ProtocolType { }; // manufacturer categories -enum class Manufacturer { Behringer, Midas, AllenHeath, Yamaha, Unknown }; +enum class Manufacturer { Behringer, Midas, AllenHeath, Yamaha, DiGiCo, Unknown }; // capabilities descriptor for each console type struct MixerCapabilities { diff --git a/src/protocol/ProtocolFactory.cpp b/src/protocol/ProtocolFactory.cpp index 265aef1..70e4237 100644 --- a/src/protocol/ProtocolFactory.cpp +++ b/src/protocol/ProtocolFactory.cpp @@ -9,6 +9,7 @@ #include "allenheath/SQProtocol.h" #include "behringer/WingProtocol.h" #include "behringer/X32Protocol.h" +#include "digico/DiGiCoProtocol.h" #include "yamaha/YamahaCLProtocol.h" #include "yamaha/YamahaDM7Protocol.h" #include "yamaha/YamahaQLProtocol.h" @@ -67,6 +68,12 @@ MixerProtocol* ProtocolFactory::create(const MixerCapabilities& caps, QObject* p case ConsoleType::DM7: return new YamahaDM7Protocol(caps, parent); + case ConsoleType::SD7: + case ConsoleType::SD9: + case ConsoleType::SD11: + case ConsoleType::SD12: + return new DiGiCoProtocol(caps, parent); + case ConsoleType::Loopback: return new LoopbackProtocol(caps, parent); @@ -101,6 +108,10 @@ bool ProtocolFactory::isImplemented(ConsoleType type) { case ConsoleType::CL3: case ConsoleType::CL5: case ConsoleType::DM7: + case ConsoleType::SD7: + case ConsoleType::SD9: + case ConsoleType::SD11: + case ConsoleType::SD12: case ConsoleType::Loopback: return true; default: diff --git a/src/protocol/digico/DiGiCoProtocol.cpp b/src/protocol/digico/DiGiCoProtocol.cpp new file mode 100644 index 0000000..482dd2b --- /dev/null +++ b/src/protocol/digico/DiGiCoProtocol.cpp @@ -0,0 +1,322 @@ +#include "DiGiCoProtocol.h" +#include "../../core/Cue.h" +#include +#include + +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); +} + +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")); +} + +// --- connection ------------------------------------------------------------ + +bool DiGiCoProtocol::connect(const QString& host, int port) { + if (m_connectionState == ConnectionState::Connected || + m_connectionState == ConnectionState::Connecting) { + disconnect(); + } + + m_host = host; + m_port = port; + + setConnectionState(ConnectionState::Connecting); + setStatus(QString("Connecting to %1:%2...").arg(host).arg(port)); + + return m_transport.connect(host, port); +} + +void DiGiCoProtocol::disconnect() { + m_keepAliveTimer.stop(); + m_transport.disconnect(); + + m_parameterCache.clear(); + m_receiveBuffer.clear(); + m_latencyMs = 0; + + setConnectionState(ConnectionState::Disconnected); + setStatus("Disconnected"); + 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()); +} + +// --- parameters ------------------------------------------------------------ + +void DiGiCoProtocol::sendParameter(const QString& path, const QVariant& value) { + if (m_connectionState != ConnectionState::Connected) + 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); +} + +void DiGiCoProtocol::requestParameterAsync(const QString& path, ParameterCallback callback) { + if (!callback) + return; + if (m_parameterCache.contains(path)) + callback(path, m_parameterCache[path], true); + else + callback(path, QVariant(), false); +} + +void DiGiCoProtocol::recallSnapshot(const Cue& cue) { + if (m_connectionState != ConnectionState::Connected) + 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) + 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); + + // Walk complete frames: tag(4) + length(4, BE) + body(length). + while (m_receiveBuffer.size() >= 8) { + quint32 length = qFromBigEndian( + reinterpret_cast(m_receiveBuffer.constData() + 4)); + + if (static_cast(m_receiveBuffer.size()) < 8 + length) + break; + + QByteArray frame = m_receiveBuffer.left(8 + length); + QByteArray tag = m_receiveBuffer.left(4); + QByteArray body = m_receiveBuffer.mid(8, length); + m_receiveBuffer.remove(0, 8 + length); + + // 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(); +} + +void DiGiCoProtocol::onTransportDisconnected() { + m_keepAliveTimer.stop(); + setConnectionState(ConnectionState::Disconnected); + setStatus("Disconnected"); + emit disconnected(); +} + +void DiGiCoProtocol::onTransportError(const QString& error) { + setStatus(error); + emit connectionError(error); +} + +void DiGiCoProtocol::onTransportConnectionLost() { + setConnectionState(ConnectionState::Reconnecting); + setStatus("Connection lost, reconnecting..."); + emit connectionLost(); +} + +void DiGiCoProtocol::onDataReceived(const QByteArray& data) { parseProtocolData(data); } + +void DiGiCoProtocol::onKeepAliveTimeout() { + if (m_connectionState == ConnectionState::Connected) + m_transport.send(buildSubscribeMixing()); +} + +void DiGiCoProtocol::onReconnecting(int attempt, int maxAttempts) { + setStatus(QString("Reconnecting (attempt %1/%2)...").arg(attempt).arg(maxAttempts)); +} + +void DiGiCoProtocol::setStatus(const QString& status) { + if (m_statusMessage != status) { + m_statusMessage = status; + emit connectionStatusChanged(status); + } +} + +void DiGiCoProtocol::setConnectionState(ConnectionState state) { + if (m_connectionState != state) { + m_connectionState = state; + emit connectionStateChanged(state); + } +} + +} // namespace OpenMix diff --git a/src/protocol/digico/DiGiCoProtocol.h b/src/protocol/digico/DiGiCoProtocol.h new file mode 100644 index 0000000..872e823 --- /dev/null +++ b/src/protocol/digico/DiGiCoProtocol.h @@ -0,0 +1,103 @@ +#pragma once + +#include "../MixerCapabilities.h" +#include "../MixerProtocol.h" +#include "../transport/TcpTransport.h" +#include +#include +#include + +namespace OpenMix { + +// DiGiCo SD-series console driver. +// +// 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. +class DiGiCoProtocol : public MixerProtocol { + Q_OBJECT + + public: + explicit DiGiCoProtocol(const MixerCapabilities& caps, QObject* parent = nullptr); + ~DiGiCoProtocol() override; + + [[nodiscard]] QString protocolName() const override { return m_capabilities.displayName; } + [[nodiscard]] QString protocolDescription() const override { + return m_capabilities.displayName + " SD Protocol"; + } + + [[nodiscard]] bool connect(const QString& host, int port) override; + void disconnect() override; + [[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; } + + void sendParameter(const QString& path, const QVariant& value) override; + [[nodiscard]] QVariant getParameter(const QString& path) override; + void requestParameter(const QString& path) override; + void requestParameterAsync(const QString& path, ParameterCallback callback) override; + + void recallSnapshot(const Cue& cue) override; + void recallScene(int sceneNumber) 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); + + private: + void setStatus(const QString& status); + void setConnectionState(ConnectionState state); + void sendHandshake(); + + MixerCapabilities m_capabilities; + TcpTransport m_transport; + QMap m_parameterCache; + + 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; +}; + +} // namespace OpenMix diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 78d824b..697fff0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -193,6 +193,29 @@ target_include_directories(test_undo_commands PRIVATE ) add_test(NAME UndoCommandsTest COMMAND test_undo_commands) +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/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_digico_protocol PRIVATE + Qt6::Core + Qt6::Network + Qt6::Test +) +target_include_directories(test_digico_protocol PRIVATE + ${CMAKE_SOURCE_DIR}/src +) +if(WIN32) + target_compile_definitions(test_digico_protocol PRIVATE NOMINMAX) +endif() +add_test(NAME DiGiCoProtocolTest COMMAND test_digico_protocol) + add_executable(test_allenheath_parsing test_allenheath_parsing.cpp ${CMAKE_SOURCE_DIR}/src/protocol/allenheath/AllenHeathTcpProtocol.cpp diff --git a/tests/test_digico_protocol.cpp b/tests/test_digico_protocol.cpp new file mode 100644 index 0000000..83aa1cd --- /dev/null +++ b/tests/test_digico_protocol.cpp @@ -0,0 +1,79 @@ +#include "protocol/MixerCapabilities.h" +#include "protocol/digico/DiGiCoProtocol.h" +#include +#include + +using namespace OpenMix; + +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 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 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 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 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 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); + } +}; + +QTEST_MAIN(TestDiGiCoProtocol) +#include "test_digico_protocol.moc" From 3603efac4fe64be64dcf9621eb68b22847832083 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 15:58:31 -0400 Subject: [PATCH 47/92] feat: Show Cue System (SCS) outbound OSC integration --- CMakeLists.txt | 2 + src/app/Application.cpp | 9 ++++ src/app/Application.h | 3 ++ src/app/ScsClient.cpp | 77 ++++++++++++++++++++++++++++++++++ src/app/ScsClient.h | 57 +++++++++++++++++++++++++ src/ui/RemoteControlDialog.cpp | 33 +++++++++++++++ src/ui/RemoteControlDialog.h | 5 +++ tests/CMakeLists.txt | 8 ++++ tests/test_scs_client.cpp | 57 +++++++++++++++++++++++++ 9 files changed, 251 insertions(+) create mode 100644 src/app/ScsClient.cpp create mode 100644 src/app/ScsClient.h create mode 100644 tests/test_scs_client.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 196ebc7..a9fcf71 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -85,6 +85,7 @@ set(SOURCES src/app/QLabClient.cpp src/app/ReaperClient.cpp src/app/CuePlayerClient.cpp + src/app/ScsClient.cpp src/core/Cue.cpp src/core/CueList.cpp src/core/Show.cpp @@ -199,6 +200,7 @@ set(HEADERS src/app/QLabClient.h src/app/ReaperClient.h src/app/CuePlayerClient.h + src/app/ScsClient.h src/core/Cue.h src/core/CueList.h src/core/Show.h diff --git a/src/app/Application.cpp b/src/app/Application.cpp index a15706a..0cf86dd 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -1,6 +1,7 @@ #include "Application.h" #include "OscRemoteServer.h" #include "CuePlayerClient.h" +#include "ScsClient.h" #include "QLabClient.h" #include "ReaperClient.h" #include "ui/MainWindow.h" @@ -73,6 +74,7 @@ Application::Application(QObject* parent) : QObject(parent) { m_qLabClient = new QLabClient(this); m_reaperClient = new ReaperClient(this); m_cuePlayerClient = new CuePlayerClient(this); + m_scsClient = new ScsClient(this); // timecode triggers + channel monitor m_timecodeTriggers = new TimecodeTriggerList(this); @@ -217,6 +219,13 @@ void Application::initialize() { m_cuePlayerClient->play(); }); + // Show Cue System (SCS): fire the master GO on each cue fire + m_scsClient->loadFromSettings(); + connect(m_playbackEngine, &PlaybackEngine::cueExecuted, this, [this](int index) { + if (m_scsClient->isEnabled() && index >= 0) + m_scsClient->go(); + }); + // REAPER virtual sound check: drop/jump a marker on each cue fire m_reaperClient->loadFromSettings(); connect(m_playbackEngine, &PlaybackEngine::cueExecuted, this, [this](int index) { diff --git a/src/app/Application.h b/src/app/Application.h index 6918afd..2c4747c 100644 --- a/src/app/Application.h +++ b/src/app/Application.h @@ -25,6 +25,7 @@ class OscRemoteServer; class QLabClient; class ReaperClient; class CuePlayerClient; +class ScsClient; class TimecodeTriggerList; class ChannelMonitor; class ScribbleController; @@ -75,6 +76,7 @@ class Application : public QObject { [[nodiscard]] QLabClient* qLabClient() { return m_qLabClient; } [[nodiscard]] ReaperClient* reaperClient() { return m_reaperClient; } [[nodiscard]] CuePlayerClient* cuePlayerClient() { return m_cuePlayerClient; } + [[nodiscard]] ScsClient* scsClient() { return m_scsClient; } // timecode triggers + channel monitor [[nodiscard]] TimecodeTriggerList* timecodeTriggers() { return m_timecodeTriggers; } @@ -151,6 +153,7 @@ class Application : public QObject { QLabClient* m_qLabClient; ReaperClient* m_reaperClient; CuePlayerClient* m_cuePlayerClient; + ScsClient* m_scsClient; bool m_recordFadersActive = false; diff --git a/src/app/ScsClient.cpp b/src/app/ScsClient.cpp new file mode 100644 index 0000000..8d8cbd6 --- /dev/null +++ b/src/app/ScsClient.cpp @@ -0,0 +1,77 @@ +#include "ScsClient.h" + +#include +#include + +namespace OpenMix { + +ScsClient::ScsClient(QObject* parent) : QObject(parent) { rebuildAddress(); } + +ScsClient::~ScsClient() { + if (m_address) + lo_address_free(m_address); +} + +void ScsClient::setTarget(const QString& host, int port) { + m_host = host; + m_port = port > 0 ? port : SCS_DEFAULT_PORT; + rebuildAddress(); +} + +void ScsClient::rebuildAddress() { + if (m_address) { + lo_address_free(m_address); + m_address = nullptr; + } + m_address = + lo_address_new(m_host.toUtf8().constData(), QByteArray::number(m_port).constData()); +} + +void ScsClient::send(const QString& address) { + if (!m_enabled || !m_address) + return; + // Every control message carries a trailing string arg (control password, + // empty by default). + lo_send(m_address, address.toUtf8().constData(), "s", m_password.toUtf8().constData()); + emit sent(address); +} + +void ScsClient::go() { send(QStringLiteral("/ctrl/go")); } + +void ScsClient::stop() { send(QStringLiteral("/ctrl/stopall")); } + +void ScsClient::fade() { send(QStringLiteral("/ctrl/fadeall")); } + +void ScsClient::pauseResume() { send(QStringLiteral("/ctrl/pauseresumeall")); } + +void ScsClient::fireCue(const QString& cueId) { + if (!m_enabled || !m_address) + return; + // /cue/go carries the cue identifier followed by the control password. + lo_send(m_address, "/cue/go", "ss", cueId.toUtf8().constData(), + m_password.toUtf8().constData()); + emit sent(QStringLiteral("/cue/go")); +} + +void ScsClient::loadFromSettings() { + QSettings settings; + settings.beginGroup("SCS"); + m_host = settings.value("host", m_host).toString(); + m_port = settings.value("port", m_port).toInt(); + m_enabled = settings.value("enabled", false).toBool(); + m_password = settings.value("password", m_password).toString(); + settings.endGroup(); + rebuildAddress(); +} + +void ScsClient::saveToSettings() { + QSettings settings; + settings.beginGroup("SCS"); + settings.setValue("host", m_host); + settings.setValue("port", m_port); + settings.setValue("enabled", m_enabled); + settings.setValue("password", m_password); + settings.endGroup(); +} + +} // namespace OpenMix diff --git a/src/app/ScsClient.h b/src/app/ScsClient.h new file mode 100644 index 0000000..fbad58d --- /dev/null +++ b/src/app/ScsClient.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include + +namespace OpenMix { + +// Outbound link to Show Cue System (SCS) playback software over OSC (default +// UDP port 58968). On a linked cue's GO, fires the SCS master GO (/ctrl/go) so +// the external player advances in step; also exposes Stop/Fade/Pause-Resume and +// per-cue fire (/cue/go with a cue id). Each control message carries a trailing +// string argument used as an optional control password (empty when unset). +class ScsClient : public QObject { + Q_OBJECT + + public: + static constexpr int SCS_DEFAULT_PORT = 58968; + + explicit ScsClient(QObject* parent = nullptr); + ~ScsClient() override; + + void setTarget(const QString& host, int port); + [[nodiscard]] QString host() const { return m_host; } + [[nodiscard]] int port() const { return m_port; } + + void setEnabled(bool enabled) { m_enabled = enabled; } + [[nodiscard]] bool isEnabled() const { return m_enabled; } + + void setPassword(const QString& password) { m_password = password; } + [[nodiscard]] QString password() const { return m_password; } + + void loadFromSettings(); + void saveToSettings(); + + public slots: + void go(); // /ctrl/go + void stop(); // /ctrl/stopall + void fade(); // /ctrl/fadeall + void pauseResume(); // /ctrl/pauseresumeall + void fireCue(const QString& cueId); // /cue/go + + signals: + void sent(const QString& address); + + private: + void rebuildAddress(); + void send(const QString& address); + + lo_address m_address = nullptr; + QString m_host = QStringLiteral("127.0.0.1"); + int m_port = SCS_DEFAULT_PORT; + bool m_enabled = false; + QString m_password; +}; + +} // namespace OpenMix diff --git a/src/ui/RemoteControlDialog.cpp b/src/ui/RemoteControlDialog.cpp index 8f2f18d..532e007 100644 --- a/src/ui/RemoteControlDialog.cpp +++ b/src/ui/RemoteControlDialog.cpp @@ -4,6 +4,7 @@ #include "app/CuePlayerClient.h" #include "app/QLabClient.h" #include "app/ReaperClient.h" +#include "app/ScsClient.h" #include "midi/MidiInputManager.h" #include @@ -117,6 +118,22 @@ void RemoteControlDialog::setupUi() { cpForm->addRow(tr("Port:"), m_cuePlayerPort); layout->addWidget(cpBox); + // --- Show Cue System (SCS) -------------------------------------------- + auto* scsBox = new QGroupBox(tr("Show Cue System (SCS)"), this); + auto* scsForm = new QFormLayout(scsBox); + m_scsEnabled = new QCheckBox(tr("Fire SCS GO on cue fire"), scsBox); + m_scsHost = new QLineEdit(scsBox); + m_scsHost->setPlaceholderText(tr("127.0.0.1")); + m_scsPort = new QSpinBox(scsBox); + m_scsPort->setRange(1, 65535); + m_scsPassword = new QLineEdit(scsBox); + m_scsPassword->setPlaceholderText(tr("(optional control password)")); + scsForm->addRow(m_scsEnabled); + scsForm->addRow(tr("Host:"), m_scsHost); + scsForm->addRow(tr("Port:"), m_scsPort); + scsForm->addRow(tr("Password:"), m_scsPassword); + layout->addWidget(scsBox); + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); connect(buttons, &QDialogButtonBox::accepted, this, &RemoteControlDialog::accept); connect(buttons, &QDialogButtonBox::rejected, this, &RemoteControlDialog::reject); @@ -159,6 +176,13 @@ void RemoteControlDialog::loadValues() { m_cuePlayerHost->setText(cp->host()); m_cuePlayerPort->setValue(cp->port()); } + + if (ScsClient* scs = m_app->scsClient()) { + m_scsEnabled->setChecked(scs->isEnabled()); + m_scsHost->setText(scs->host()); + m_scsPort->setValue(scs->port()); + m_scsPassword->setText(scs->password()); + } } void RemoteControlDialog::applyValues() { @@ -213,6 +237,15 @@ void RemoteControlDialog::applyValues() { m_cuePlayerPort->value()); cp->saveToSettings(); } + + if (ScsClient* scs = m_app->scsClient()) { + scs->setEnabled(m_scsEnabled->isChecked()); + scs->setTarget(m_scsHost->text().trimmed().isEmpty() ? QStringLiteral("127.0.0.1") + : m_scsHost->text().trimmed(), + m_scsPort->value()); + scs->setPassword(m_scsPassword->text()); + scs->saveToSettings(); + } } void RemoteControlDialog::accept() { diff --git a/src/ui/RemoteControlDialog.h b/src/ui/RemoteControlDialog.h index a17abf4..ba8c5ef 100644 --- a/src/ui/RemoteControlDialog.h +++ b/src/ui/RemoteControlDialog.h @@ -58,6 +58,11 @@ class RemoteControlDialog : public QDialog { QCheckBox* m_cuePlayerEnabled = nullptr; QLineEdit* m_cuePlayerHost = nullptr; QSpinBox* m_cuePlayerPort = nullptr; + + QCheckBox* m_scsEnabled = nullptr; + QLineEdit* m_scsHost = nullptr; + QSpinBox* m_scsPort = nullptr; + QLineEdit* m_scsPassword = nullptr; }; } // namespace OpenMix diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 697fff0..179ab2a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -193,6 +193,14 @@ target_include_directories(test_undo_commands PRIVATE ) add_test(NAME UndoCommandsTest COMMAND test_undo_commands) +add_executable(test_scs_client + test_scs_client.cpp + ${CMAKE_SOURCE_DIR}/src/app/ScsClient.cpp +) +target_link_libraries(test_scs_client PRIVATE Qt6::Core Qt6::Test PkgConfig::LIBLO) +target_include_directories(test_scs_client PRIVATE ${CMAKE_SOURCE_DIR}/src) +add_test(NAME ScsClientTest COMMAND test_scs_client) + add_executable(test_digico_protocol test_digico_protocol.cpp ${CMAKE_SOURCE_DIR}/src/protocol/digico/DiGiCoProtocol.cpp diff --git a/tests/test_scs_client.cpp b/tests/test_scs_client.cpp new file mode 100644 index 0000000..714156f --- /dev/null +++ b/tests/test_scs_client.cpp @@ -0,0 +1,57 @@ +#include "app/ScsClient.h" +#include +#include + +using namespace OpenMix; + +class TestScsClient : public QObject { + Q_OBJECT + + private slots: + void transportButtonsSendCtrlAddresses() { + ScsClient c; + c.setTarget("127.0.0.1", 58968); + c.setEnabled(true); + + QSignalSpy sent(&c, &ScsClient::sent); + c.go(); + c.stop(); + c.fade(); + c.pauseResume(); + + QCOMPARE(sent.count(), 4); + QCOMPARE(sent.at(0).at(0).toString(), QString("/ctrl/go")); + QCOMPARE(sent.at(1).at(0).toString(), QString("/ctrl/stopall")); + QCOMPARE(sent.at(2).at(0).toString(), QString("/ctrl/fadeall")); + QCOMPARE(sent.at(3).at(0).toString(), QString("/ctrl/pauseresumeall")); + } + + void fireCueSendsCueGo() { + ScsClient c; + c.setTarget("127.0.0.1", 58968); + c.setEnabled(true); + + QSignalSpy sent(&c, &ScsClient::sent); + c.fireCue("Q1"); + + QCOMPARE(sent.count(), 1); + QCOMPARE(sent.at(0).at(0).toString(), QString("/cue/go")); + } + + void defaultPortIsScs() { + ScsClient c; + QCOMPARE(c.port(), 58968); + } + + void disabledSendsNothing() { + ScsClient c; + c.setTarget("127.0.0.1", 58968); + QSignalSpy sent(&c, &ScsClient::sent); + c.go(); + c.fireCue("Q1"); + QCOMPARE(sent.count(), 0); + } +}; + +QTEST_MAIN(TestScsClient) +#include "test_scs_client.moc" From aecbd08d77e7b10516bf2c53f90c5b42f27767a4 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 16:32:20 -0400 Subject: [PATCH 48/92] feat: standby cue highlight, filter pinning, unified status colors --- src/ui/ConnectionStateWidget.cpp | 11 ++++++----- src/ui/CueConfidenceIndicator.cpp | 11 ++++++----- src/ui/CueFilterProxyModel.cpp | 5 +++++ src/ui/CueTableModel.cpp | 24 +++++++++++++++++++----- src/ui/MainWindow.cpp | 5 +++++ 5 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/ui/ConnectionStateWidget.cpp b/src/ui/ConnectionStateWidget.cpp index b5363c7..e280e79 100644 --- a/src/ui/ConnectionStateWidget.cpp +++ b/src/ui/ConnectionStateWidget.cpp @@ -1,4 +1,5 @@ #include "ConnectionStateWidget.h" +#include "theme/Theme.h" #include #include @@ -87,15 +88,15 @@ void ConnectionStateWidget::paintEvent([[maybe_unused]] QPaintEvent* event) { QColor ConnectionStateWidget::stateColor() const { switch (m_state) { case ConnectionState::Disconnected: - return QColor(200, 60, 60); // red + return Theme::color(Theme::Colors::AccentRed); case ConnectionState::Connecting: - return QColor(220, 180, 60); // yellow + return Theme::color(Theme::Colors::AccentAmber); case ConnectionState::Connected: - return QColor(60, 180, 60); // green + return Theme::color(Theme::Colors::AccentGreen); case ConnectionState::Reconnecting: - return QColor(220, 140, 60); // orange + return Theme::color(Theme::Colors::AccentAmberHover); default: - return QColor(128, 128, 128); // gray + return Theme::color(Theme::Colors::TextTertiary); } } diff --git a/src/ui/CueConfidenceIndicator.cpp b/src/ui/CueConfidenceIndicator.cpp index f4b0f62..45f6bac 100644 --- a/src/ui/CueConfidenceIndicator.cpp +++ b/src/ui/CueConfidenceIndicator.cpp @@ -1,4 +1,5 @@ #include "CueConfidenceIndicator.h" +#include "theme/Theme.h" #include "core/Cue.h" #include "core/CueList.h" #include "core/CueValidator.h" @@ -73,15 +74,15 @@ QSize CueConfidenceIndicator::minimumSizeHint() const { return QSize(12, 12); } QColor CueConfidenceIndicator::colorForLevel(ConfidenceLevel level) { switch (level) { case ConfidenceLevel::Good: - return QColor(76, 175, 80); // green + return Theme::color(Theme::Colors::AccentGreen); case ConfidenceLevel::Warning: - return QColor(255, 193, 7); // amber + return Theme::color(Theme::Colors::AccentAmber); case ConfidenceLevel::Error: - return QColor(244, 67, 54); // red + return Theme::color(Theme::Colors::AccentRed); case ConfidenceLevel::Unknown: - return QColor(158, 158, 158); // gray + return Theme::color(Theme::Colors::TextTertiary); } - return QColor(158, 158, 158); + return Theme::color(Theme::Colors::TextTertiary); } QString CueConfidenceIndicator::iconForLevel(ConfidenceLevel level) { diff --git a/src/ui/CueFilterProxyModel.cpp b/src/ui/CueFilterProxyModel.cpp index 1d772c4..aca0974 100644 --- a/src/ui/CueFilterProxyModel.cpp +++ b/src/ui/CueFilterProxyModel.cpp @@ -115,6 +115,11 @@ bool CueFilterProxyModel::filterAcceptsRow(int sourceRow, [[maybe_unused]] const if (!model) return true; + // The running and standby cues stay visible regardless of filters, so an + // operator never loses sight of what is playing or queued next. + if (sourceRow == model->currentCueIndex() || sourceRow == model->standbyCueIndex()) + return true; + const Cue* cue = model->cueAt(sourceRow); if (!cue) return true; diff --git a/src/ui/CueTableModel.cpp b/src/ui/CueTableModel.cpp index 6d691bf..55e8b41 100644 --- a/src/ui/CueTableModel.cpp +++ b/src/ui/CueTableModel.cpp @@ -49,8 +49,17 @@ QVariant CueTableModel::data(const QModelIndex& index, int role) const { if (role == Qt::DisplayRole || role == Qt::EditRole) { switch (col) { - case ColNumber: - return QString::number(cue.number(), 'f', 1); + case ColNumber: { + const QString num = QString::number(cue.number(), 'f', 1); + // Marker only on the display text, never the edit text. + if (role == Qt::DisplayRole) { + if (row == m_currentIndex) + return QString("▶ ") + num; + if (row == m_standbyIndex) + return QString("→ ") + num; + } + return num; + } case ColName: return cue.name(); case ColType: @@ -100,19 +109,24 @@ QVariant CueTableModel::data(const QModelIndex& index, int role) const { } if (role == Qt::BackgroundRole) { + // green = running now, amber = standing by next. Distinct so an + // operator reads the list at a glance under stage lighting. if (row == m_currentIndex) { - return QBrush(QColor(34, 197, 94, 220)); + return QBrush(Theme::withAlpha(Theme::Colors::AccentGreen, 220)); + } + if (row == m_standbyIndex) { + return QBrush(Theme::withAlpha(Theme::Colors::AccentAmber, 150)); } } if (role == Qt::ForegroundRole) { - if (row == m_currentIndex) { + if (row == m_currentIndex || row == m_standbyIndex) { return QBrush(Theme::color(Theme::Colors::TextPrimary)); } } if (role == Qt::FontRole) { - if (row == m_currentIndex) { + if (row == m_currentIndex || row == m_standbyIndex) { QFont font; font.setBold(true); return font; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 298696f..1bd9ae5 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -601,6 +601,11 @@ void MainWindow::createStatusBar() { m_cueStatusLabel = new QLabel(tr("No cues")); m_currentCueLabel = new QLabel(); m_nextCueLabel = new QLabel(); + // Reinforce the list's color language: running = green, standby = amber. + m_currentCueLabel->setStyleSheet( + QString("color: %1; font-weight: bold; padding: 0 8px;").arg(Theme::Colors::AccentGreen)); + m_nextCueLabel->setStyleSheet( + QString("color: %1; font-weight: bold; padding: 0 8px;").arg(Theme::Colors::AccentAmber)); statusBar()->addWidget(m_connectionStatusLabel); statusBar()->addWidget(m_cueStatusLabel, 1); From c37ecc12f5ce007e04cd42b02af16b22d0421589 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 16:39:21 -0400 Subject: [PATCH 49/92] feat: type-to-jump box, cue context menu, type icons, fade column --- src/ui/CueItemDelegates.cpp | 50 ++++++++++++++++++++ src/ui/CueListView.cpp | 71 +++++++++++++++++++++++++++- src/ui/CueListView.h | 4 ++ src/ui/CueTableModel.cpp | 5 ++ src/ui/CueTableModel.h | 1 + src/ui/MainWindow.cpp | 93 +++++++++++++++++++++++++++++-------- src/ui/MainWindow.h | 5 +- 7 files changed, 206 insertions(+), 23 deletions(-) diff --git a/src/ui/CueItemDelegates.cpp b/src/ui/CueItemDelegates.cpp index 6113432..7ea55e4 100644 --- a/src/ui/CueItemDelegates.cpp +++ b/src/ui/CueItemDelegates.cpp @@ -9,6 +9,8 @@ #include #include #include +#include +#include #include namespace OpenMix { @@ -126,6 +128,54 @@ void CueTypeDelegate::paint(QPainter* painter, const QStyleOptionViewItem& optio opt.backgroundBrush = bgData.value(); } + // distinct shape+color per cue type reads faster than the word alone + const CueType type = stringToCueType(index.data(Qt::DisplayRole).toString()); + const int sz = 10; + QRect box(opt.rect.left() + 8, opt.rect.center().y() - sz / 2, sz, sz); + + QColor c; + switch (type) { + case CueType::Snapshot: c = Theme::color(Theme::Colors::AccentGreen); break; + case CueType::Stop: c = Theme::color(Theme::Colors::AccentRed); break; + case CueType::GoTo: c = Theme::color(Theme::Colors::AccentBlue); break; + case CueType::Wait: c = Theme::color(Theme::Colors::AccentAmber); break; + case CueType::Macro: c = Theme::color(Theme::Colors::AccentBlueHover); break; + } + + painter->save(); + painter->setRenderHint(QPainter::Antialiasing); + painter->setPen(Qt::NoPen); + painter->setBrush(c); + switch (type) { + case CueType::Snapshot: + painter->drawEllipse(box); + break; + case CueType::Stop: + painter->drawRect(box); + break; + case CueType::GoTo: { + QPolygon tri; + tri << QPoint(box.left(), box.top()) << QPoint(box.right(), box.center().y()) + << QPoint(box.left(), box.bottom()); + painter->drawPolygon(tri); + break; + } + case CueType::Wait: { + QPolygon dia; + dia << QPoint(box.center().x(), box.top()) << QPoint(box.right(), box.center().y()) + << QPoint(box.center().x(), box.bottom()) << QPoint(box.left(), box.center().y()); + painter->drawPolygon(dia); + break; + } + case CueType::Macro: + for (int i = 0; i < 3; ++i) + painter->drawRect(box.left(), box.top() + i * 4, sz, 2); + break; + } + painter->restore(); + + // indent the text past the marker + opt.rect.setLeft(box.right() + 6); QStyledItemDelegate::paint(painter, opt, index); } diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index 2383e09..8cae99e 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -84,12 +85,18 @@ void CueListView::setupUi() { m_tableView->horizontalHeader()->setStretchLastSection(true); m_tableView->setShowGrid(false); - // column widths - m_tableView->setColumnWidth(CueTableModel::ColNumber, 60); + // column widths (ColNumber wider to fit the ▶/→ standby markers) + m_tableView->setColumnWidth(CueTableModel::ColNumber, 78); m_tableView->setColumnWidth(CueTableModel::ColName, 150); m_tableView->setColumnWidth(CueTableModel::ColType, 100); m_tableView->setColumnWidth(CueTableModel::ColGroup, 100); m_tableView->setColumnWidth(CueTableModel::ColTags, 120); + m_tableView->setColumnWidth(CueTableModel::ColFade, 72); + + // right-click context menu for quick edits + m_tableView->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_tableView, &QTableView::customContextMenuRequested, this, + &CueListView::showContextMenu); // connections connect(m_tableView->selectionModel(), &QItemSelectionModel::selectionChanged, this, @@ -444,6 +451,60 @@ void CueListView::setEditingLocked(bool locked) { QAbstractItemView::AnyKeyPressed); } +void CueListView::showContextMenu(const QPoint& pos) { + QModelIndex at = m_tableView->indexAt(pos); + if (at.isValid()) + m_tableView->selectRow(at.row()); + + const int row = selectedCueIndex(); + QMenu menu(this); + + if (row >= 0) { + QAction* rename = menu.addAction(tr("Rename")); + rename->setShortcut(QKeySequence(Qt::Key_F2)); + connect(rename, &QAction::triggered, this, &CueListView::beginRenameSelected); + + QMenu* typeMenu = menu.addMenu(tr("Change Type")); + const CueType types[] = {CueType::Snapshot, CueType::Stop, CueType::GoTo, + CueType::Wait, CueType::Macro}; + for (CueType t : types) { + QAction* a = typeMenu->addAction(cueTypeToString(t)); + connect(a, &QAction::triggered, this, [this, row, t]() { + m_model->setData(m_model->index(row, CueTableModel::ColType), + cueTypeToString(t), Qt::EditRole); + }); + } + + menu.addSeparator(); + menu.addAction(tr("Duplicate"), this, &CueListView::duplicateSelectedCue); + menu.addAction(tr("Copy"), this, &CueListView::copySelectedCue); + } + + QAction* paste = menu.addAction(tr("Paste")); + paste->setEnabled(hasClipboardCue()); + connect(paste, &QAction::triggered, this, &CueListView::pasteCue); + + if (row >= 0) { + menu.addSeparator(); + menu.addAction(tr("Jump to Standby"), this, &CueListView::jumpToSelectedCue); + menu.addAction(tr("Delete"), this, &CueListView::deleteSelectedCue); + } + + menu.exec(m_tableView->viewport()->mapToGlobal(pos)); +} + +void CueListView::beginRenameSelected() { + const int row = selectedCueIndex(); + if (row < 0) + return; + QModelIndex src = m_model->index(row, CueTableModel::ColName); + QModelIndex proxy = m_proxyModel->mapFromSource(src); + if (proxy.isValid()) { + m_tableView->setCurrentIndex(proxy); + m_tableView->edit(proxy); + } +} + void CueListView::onSelectionChanged() { emit cueSelected(selectedCueIndex()); } void CueListView::onCueReordered(int fromIndex, int toIndex) { @@ -502,6 +563,12 @@ bool CueListView::eventFilter(QObject* watched, QEvent* event) { // check if there's an active editor by looking for index widget bool isEditing = current.isValid() && m_tableView->indexWidget(current) != nullptr; + // F2 renames the selected cue inline + if (keyEvent->key() == Qt::Key_F2 && !isEditing) { + beginRenameSelected(); + return true; + } + // handle enter to edit current cell if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) { if (current.isValid() && !isEditing) { diff --git a/src/ui/CueListView.h b/src/ui/CueListView.h index 051eab6..da2fe6f 100644 --- a/src/ui/CueListView.h +++ b/src/ui/CueListView.h @@ -66,6 +66,10 @@ class CueListView : public QWidget { void onCueReordered(int fromIndex, int toIndex); void onFiltersChanged(); void onTabNavigationRequested(const QModelIndex& fromIndex, bool forward); + void showContextMenu(const QPoint& pos); + + public slots: + void beginRenameSelected(); // enter inline edit on the selected cue's name private: void setupUi(); diff --git a/src/ui/CueTableModel.cpp b/src/ui/CueTableModel.cpp index 55e8b41..8b93ed5 100644 --- a/src/ui/CueTableModel.cpp +++ b/src/ui/CueTableModel.cpp @@ -94,6 +94,9 @@ QVariant CueTableModel::data(const QModelIndex& index, int role) const { } return parts.isEmpty() ? QString() : tr("mute %1").arg(parts.join(", ")); } + case ColFade: + return cue.fadeTime() > 0.0 ? tr("%1s").arg(cue.fadeTime(), 0, 'f', 1) + : tr("instant"); } } @@ -178,6 +181,8 @@ QVariant CueTableModel::headerData(int section, Qt::Orientation orientation, int return tr("Positions"); case ColFx: return tr("FX"); + case ColFade: + return tr("Fade"); } return QVariant(); } diff --git a/src/ui/CueTableModel.h b/src/ui/CueTableModel.h index b6f3681..b751fcd 100644 --- a/src/ui/CueTableModel.h +++ b/src/ui/CueTableModel.h @@ -25,6 +25,7 @@ class CueTableModel : public QAbstractTableModel { ColDca, // read-only summary of DCA overrides (channel labels) ColPosition, // read-only count of positioned channels ColFx, // read-only muted FX units + ColFade, // fade duration (instant / seconds) ColCount }; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 1bd9ae5..0f14169 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -50,7 +50,13 @@ #include #include #include +#include +#include +#include #include +#include +#include +#include #include #include #include @@ -218,9 +224,13 @@ void MainWindow::createActions() { connect(m_jumpToSelectedAction, &QAction::triggered, this, [this]() { m_cueListView->jumpToSelectedCue(); }); - m_jumpAction = new QAction(tr("Ju&mp..."), this); - m_jumpAction->setToolTip(tr("Set standby to a cue by number without firing")); - connect(m_jumpAction, &QAction::triggered, this, &MainWindow::showJumpDialog); + m_jumpAction = new QAction(tr("Ju&mp to Cue"), this); + m_jumpAction->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_J)); + m_jumpAction->setToolTip(tr("Focus the jump box: type a cue number (append G to fire)")); + connect(m_jumpAction, &QAction::triggered, this, [this]() { + m_jumpEdit->setFocus(); + m_jumpEdit->selectAll(); + }); m_lockEditingAction = new QAction(tr("&Lock Editing"), this); m_lockEditingAction->setCheckable(true); @@ -538,6 +548,7 @@ void MainWindow::createMenus() { const ColumnToggle columnToggles[] = {{"&Group", CueTableModel::ColGroup, true}, {"&Tags", CueTableModel::ColTags, true}, {"&Notes", CueTableModel::ColNotes, true}, + {"F&ade", CueTableModel::ColFade, true}, {"Colo&r", CueTableModel::ColColor, true}, {"&DCAs", CueTableModel::ColDca, false}, {"&Positions", CueTableModel::ColPosition, false}, @@ -594,6 +605,48 @@ void MainWindow::createToolBars() { m_playbackToolBar->addAction(m_nextCueAction); m_playbackToolBar->addSeparator(); m_playbackToolBar->addAction(m_panicAction); + + // type-to-jump: "12.5" sets standby, "12.5G" jumps and fires + m_playbackToolBar->addSeparator(); + m_playbackToolBar->addWidget(new QLabel(tr(" Jump: "))); + m_jumpEdit = new QLineEdit(); + m_jumpEdit->setPlaceholderText(tr("12.5 or 12.5G")); + m_jumpEdit->setFixedWidth(110); + m_jumpEdit->setClearButtonEnabled(true); + m_jumpEdit->setToolTip(tr("Type a cue number and press Enter to set standby;\n" + "append G to jump and fire (e.g. 12.5G).")); + connect(m_jumpEdit, &QLineEdit::returnPressed, this, &MainWindow::onJumpEntered); + m_playbackToolBar->addWidget(m_jumpEdit); +} + +void MainWindow::onJumpEntered() { + QString text = m_jumpEdit->text().trimmed(); + if (text.isEmpty()) + return; + + bool fireAfter = text.endsWith('g', Qt::CaseInsensitive); + if (fireAfter) + text.chop(1); + + bool ok = false; + double number = text.trimmed().toDouble(&ok); + if (!ok) { + statusBar()->showMessage(tr("Jump: '%1' is not a cue number").arg(text.trimmed()), 3000); + return; + } + + auto idx = m_app->show()->cueList()->indexOfNumber(number); + if (!idx) { + statusBar()->showMessage(tr("Jump: no cue numbered %1").arg(number), 3000); + return; + } + + m_app->playbackEngine()->setStandbyIndex(*idx); + m_jumpEdit->clear(); + if (fireAfter) + go(); + else + m_cueListView->setFocus(); } void MainWindow::createStatusBar() { @@ -838,8 +891,9 @@ void MainWindow::closeEvent(QCloseEvent* event) { } void MainWindow::keyPressEvent(QKeyEvent* event) { - // spacebar for GO (when not editing text) - if (event->key() == Qt::Key_Space && !m_cueEditor->hasFocus()) { + // spacebar for GO, but never while text is being entered: a cell editor, + // the filter box, a spin box, or an editable combo must receive the space. + if (event->key() == Qt::Key_Space && !isTextEntryFocused()) { go(); event->accept(); return; @@ -847,6 +901,20 @@ void MainWindow::keyPressEvent(QKeyEvent* event) { QMainWindow::keyPressEvent(event); } +bool MainWindow::isTextEntryFocused() const { + QWidget* focus = QApplication::focusWidget(); + if (!focus) + return false; + if (m_cueEditor->isAncestorOf(focus) || m_cueEditor->hasFocus()) + return true; + // any line edit, text edit, spin box, or open combo popup swallows space + if (qobject_cast(focus) || qobject_cast(focus) || + qobject_cast(focus) || qobject_cast(focus) || + qobject_cast(focus)) + return true; + return false; +} + void MainWindow::resizeEvent(QResizeEvent* event) { QMainWindow::resizeEvent(event); } bool MainWindow::maybeSave() { @@ -991,21 +1059,6 @@ void MainWindow::renumberCues() { m_cueListView->refreshAll(); } -void MainWindow::showJumpDialog() { - CueList* list = m_app->show()->cueList(); - if (list->isEmpty()) - return; - bool ok = false; - const double number = QInputDialog::getDouble(this, tr("Jump to Cue"), tr("Cue number:"), 1.0, - 0.0, 99999.0, 2, &ok); - if (!ok) - return; - if (auto idx = list->indexOfNumber(number)) - m_app->playbackEngine()->setStandbyIndex(*idx); - else - QMessageBox::information(this, tr("Jump to Cue"), - tr("No cue numbered %1.").arg(number)); -} void MainWindow::recordOffsets() { if (!m_app->mixer() || !m_app->mixer()->isConnected()) { diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index f79305b..8b3d755 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -7,6 +7,7 @@ class QAction; class QMenu; class QToolBar; class QLabel; +class QLineEdit; class QSplitter; namespace OpenMix { @@ -56,7 +57,7 @@ class MainWindow : public QMainWindow { void addCue(); void deleteCue(); void renumberCues(); - void showJumpDialog(); + void onJumpEntered(); void toggleLockEditing(); void recordOffsets(); @@ -110,6 +111,7 @@ class MainWindow : public QMainWindow { void onBubbleButtonClicked(const QString& id, bool checked); private: + [[nodiscard]] bool isTextEntryFocused() const; void setupUi(); void createActions(); void registerShortcuts(); @@ -242,6 +244,7 @@ class MainWindow : public QMainWindow { QLabel* m_cueStatusLabel; QLabel* m_currentCueLabel; QLabel* m_nextCueLabel; + QLineEdit* m_jumpEdit = nullptr; }; } // namespace OpenMix From beb064a068693ddebd3b755a0a116e1f62f29e99 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 16:44:36 -0400 Subject: [PATCH 50/92] feat: bigger live controls, grouped console list, persisted columns, roomier editor --- src/ui/ConnectionPanel.cpp | 54 +++++++++++++++++++------------ src/ui/ConnectionPanel.h | 1 + src/ui/CueConfidenceIndicator.cpp | 4 +-- src/ui/CueEditor.cpp | 16 ++++----- src/ui/CueEditor.h | 1 - src/ui/CueListView.cpp | 26 +++++++++++++++ src/ui/CueListView.h | 2 ++ src/ui/DCAWidget.cpp | 6 ++-- src/ui/MainWindow.cpp | 3 ++ 9 files changed, 76 insertions(+), 37 deletions(-) diff --git a/src/ui/ConnectionPanel.cpp b/src/ui/ConnectionPanel.cpp index 51faff4..a96b3a6 100644 --- a/src/ui/ConnectionPanel.cpp +++ b/src/ui/ConnectionPanel.cpp @@ -9,6 +9,8 @@ #include "protocol/discovery/ConsoleDiscoveryService.h" #include "theme/Icons.h" +#include + #include #include #include @@ -27,6 +29,36 @@ ConnectionPanel::ConnectionPanel(Application* app, QWidget* parent) : QWidget(pa updateUiState(); } +void ConnectionPanel::populateProtocolCombo() { + m_protocolCombo->addItem(tr("Loopback (Test)"), "loopback"); + + // group consoles under a disabled manufacturer header, stripping the + // redundant manufacturer prefix from each model so the list scans quickly + const Manufacturer order[] = {Manufacturer::Behringer, Manufacturer::Midas, + Manufacturer::AllenHeath, Manufacturer::Yamaha, + Manufacturer::DiGiCo}; + auto* model = qobject_cast(m_protocolCombo->model()); + for (Manufacturer man : order) { + const QVector group = MixerCapabilities::forManufacturer(man); + if (group.isEmpty()) + continue; + + const QString maker = group.first().manufacturerName(); + m_protocolCombo->insertSeparator(m_protocolCombo->count()); + m_protocolCombo->addItem(maker); + if (model) + if (QStandardItem* header = model->item(m_protocolCombo->count() - 1)) + header->setFlags(header->flags() & ~(Qt::ItemIsSelectable | Qt::ItemIsEnabled)); + + for (const MixerCapabilities& caps : group) { + QString label = caps.displayName; + if (label.startsWith(maker)) + label = label.mid(maker.size()).trimmed(); + m_protocolCombo->addItem(label, caps.protocolId); + } + } +} + void ConnectionPanel::setupUi() { setMinimumSize(240, 600); setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); @@ -51,27 +83,7 @@ void ConnectionPanel::setupUi() { QFormLayout* formLayout = new QFormLayout(connectionGroup); m_protocolCombo = new QComboBox(this); - - m_protocolCombo->addItem(tr("Loopback (Test)"), "loopback"); - m_protocolCombo->addItem(tr("Allen & Heath Avantis"), "avantis"); - m_protocolCombo->addItem(tr("Allen & Heath dLive"), "dlive"); - m_protocolCombo->addItem(tr("Allen & Heath GLD-80"), "gld80"); - m_protocolCombo->addItem(tr("Allen & Heath GLD-112"), "gld112"); - m_protocolCombo->addItem(tr("Allen & Heath SQ-5"), "sq5"); - m_protocolCombo->addItem(tr("Allen & Heath SQ-6"), "sq6"); - m_protocolCombo->addItem(tr("Allen & Heath SQ-7"), "sq7"); - m_protocolCombo->addItem(tr("Behringer WING"), "wing"); - m_protocolCombo->addItem(tr("Behringer X32"), "x32"); - m_protocolCombo->addItem(tr("Midas M32"), "m32"); - m_protocolCombo->addItem(tr("Yamaha CL1"), "cl1"); - m_protocolCombo->addItem(tr("Yamaha CL3"), "cl3"); - m_protocolCombo->addItem(tr("Yamaha CL5"), "cl5"); - m_protocolCombo->addItem(tr("Yamaha DM7"), "dm7"); - m_protocolCombo->addItem(tr("Yamaha QL1"), "ql1"); - m_protocolCombo->addItem(tr("Yamaha QL5"), "ql5"); - m_protocolCombo->addItem(tr("Yamaha TF1"), "tf1"); - m_protocolCombo->addItem(tr("Yamaha TF3"), "tf3"); - m_protocolCombo->addItem(tr("Yamaha TF5"), "tf5"); + populateProtocolCombo(); formLayout->addRow(tr("Protocol:"), m_protocolCombo); diff --git a/src/ui/ConnectionPanel.h b/src/ui/ConnectionPanel.h index 10c196f..fa92134 100644 --- a/src/ui/ConnectionPanel.h +++ b/src/ui/ConnectionPanel.h @@ -38,6 +38,7 @@ class ConnectionPanel : public QWidget { private: void setupUi(); + void populateProtocolCombo(); void updateUiState(); void loadFromConfig(); void saveToConfig(); diff --git a/src/ui/CueConfidenceIndicator.cpp b/src/ui/CueConfidenceIndicator.cpp index 45f6bac..3ed858f 100644 --- a/src/ui/CueConfidenceIndicator.cpp +++ b/src/ui/CueConfidenceIndicator.cpp @@ -67,9 +67,9 @@ void CueConfidenceIndicator::setConfidence(ConfidenceLevel level, const QString& QString CueConfidenceIndicator::tooltipText() const { return m_tooltipText; } -QSize CueConfidenceIndicator::sizeHint() const { return QSize(16, 16); } +QSize CueConfidenceIndicator::sizeHint() const { return QSize(20, 20); } -QSize CueConfidenceIndicator::minimumSizeHint() const { return QSize(12, 12); } +QSize CueConfidenceIndicator::minimumSizeHint() const { return QSize(18, 18); } QColor CueConfidenceIndicator::colorForLevel(ConfidenceLevel level) { switch (level) { diff --git a/src/ui/CueEditor.cpp b/src/ui/CueEditor.cpp index 1bf07f9..b8b8c22 100644 --- a/src/ui/CueEditor.cpp +++ b/src/ui/CueEditor.cpp @@ -146,12 +146,9 @@ void CueEditor::setupUi() { m_dcaOverridesGroup = new QGroupBox(tr("DCA Overrides (Mute/Label)"), this); QVBoxLayout* overridesLayout = new QVBoxLayout(m_dcaOverridesGroup); - m_dcaOverridesScroll = new QScrollArea(this); - m_dcaOverridesScroll->setWidgetResizable(true); - m_dcaOverridesScroll->setFrameShape(QFrame::NoFrame); - m_dcaOverridesScroll->setMaximumHeight(200); - - QWidget* overridesContent = new QWidget(); + // no inner scroll here: the whole editor is already scrollable, so let the + // overrides expand inline rather than nesting a second scrollbar + QWidget* overridesContent = new QWidget(m_dcaOverridesGroup); QVBoxLayout* overridesContentLayout = new QVBoxLayout(overridesContent); overridesContentLayout->setContentsMargins(0, 0, 0, 0); overridesContentLayout->setSpacing(4); @@ -200,8 +197,7 @@ void CueEditor::setupUi() { } overridesContentLayout->addStretch(); - m_dcaOverridesScroll->setWidget(overridesContent); - overridesLayout->addWidget(m_dcaOverridesScroll); + overridesLayout->addWidget(overridesContent); m_mainLayout->addWidget(m_dcaOverridesGroup); @@ -240,7 +236,7 @@ void CueEditor::setupUi() { m_notesEdit = new QTextEdit(this); m_notesEdit->setPlaceholderText(tr("Enter cue notes...")); - m_notesEdit->setMaximumHeight(80); + m_notesEdit->setMaximumHeight(120); notesLayout->addWidget(m_notesEdit); m_mainLayout->addWidget(notesGroup); @@ -342,7 +338,7 @@ void CueEditor::createChannelProfilesSection() { m_channelTable->setSelectionMode(QAbstractItemView::NoSelection); m_channelTable->setEditTriggers(QAbstractItemView::NoEditTriggers); m_channelTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); - m_channelTable->setMaximumHeight(220); + m_channelTable->setMaximumHeight(340); layout->addWidget(m_channelTable); } diff --git a/src/ui/CueEditor.h b/src/ui/CueEditor.h index cd37fa4..7ffd1ac 100644 --- a/src/ui/CueEditor.h +++ b/src/ui/CueEditor.h @@ -134,7 +134,6 @@ class CueEditor : public QWidget { // DCA overrides (mute/label per DCA) QGroupBox* m_dcaOverridesGroup; - QScrollArea* m_dcaOverridesScroll; struct DCAOverrideWidgets { QCheckBox* enableMute; QCheckBox* muteValue; diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index 8cae99e..0689269 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -93,6 +94,12 @@ void CueListView::setupUi() { m_tableView->setColumnWidth(CueTableModel::ColTags, 120); m_tableView->setColumnWidth(CueTableModel::ColFade, 72); + // columns are user-resizable; remember widths across sessions + m_tableView->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive); + restoreColumnWidths(); + connect(m_tableView->horizontalHeader(), &QHeaderView::sectionResized, this, + [this](int, int, int) { saveColumnWidths(); }); + // right-click context menu for quick edits m_tableView->setContextMenuPolicy(Qt::CustomContextMenu); connect(m_tableView, &QTableView::customContextMenuRequested, this, @@ -493,6 +500,25 @@ void CueListView::showContextMenu(const QPoint& pos) { menu.exec(m_tableView->viewport()->mapToGlobal(pos)); } +void CueListView::saveColumnWidths() { + QSettings s; + s.beginGroup("CueListColumns"); + for (int c = 0; c < CueTableModel::ColCount; ++c) + s.setValue(QString::number(c), m_tableView->columnWidth(c)); + s.endGroup(); +} + +void CueListView::restoreColumnWidths() { + QSettings s; + s.beginGroup("CueListColumns"); + for (int c = 0; c < CueTableModel::ColCount; ++c) { + const int w = s.value(QString::number(c), 0).toInt(); + if (w > 0) + m_tableView->setColumnWidth(c, w); + } + s.endGroup(); +} + void CueListView::beginRenameSelected() { const int row = selectedCueIndex(); if (row < 0) diff --git a/src/ui/CueListView.h b/src/ui/CueListView.h index da2fe6f..d706bbf 100644 --- a/src/ui/CueListView.h +++ b/src/ui/CueListView.h @@ -74,6 +74,8 @@ class CueListView : public QWidget { private: void setupUi(); void setupDelegates(); + void saveColumnWidths(); + void restoreColumnWidths(); void createActions(); void editNextCell(bool forward); QModelIndex nextEditableIndex(const QModelIndex& current, bool forward) const; diff --git a/src/ui/DCAWidget.cpp b/src/ui/DCAWidget.cpp index 8644b2a..428fbc7 100644 --- a/src/ui/DCAWidget.cpp +++ b/src/ui/DCAWidget.cpp @@ -27,7 +27,7 @@ void DCAWidget::setupUi() { m_nameLabel = new QLabel(QString("DCA %1").arg(m_dcaNumber), this); m_nameLabel->setAlignment(Qt::AlignCenter); QFont nameFont; - nameFont.setPointSize(8); + nameFont.setPointSize(9); nameFont.setBold(true); m_nameLabel->setFont(nameFont); m_nameLabel->installEventFilter(this); @@ -44,7 +44,7 @@ void DCAWidget::setupUi() { m_levelLabel = new QLabel("-inf", this); m_levelLabel->setAlignment(Qt::AlignCenter); QFont levelFont; - levelFont.setPointSize(7); + levelFont.setPointSize(9); m_levelLabel->setFont(levelFont); // fader slider (vertical) @@ -197,7 +197,7 @@ void DCAWidget::setOriginalLevel(float level) { update(); } -QSize DCAWidget::sizeHint() const { return QSize(50, 180); } +QSize DCAWidget::sizeHint() const { return QSize(56, 184); } QSize DCAWidget::minimumSizeHint() const { return QSize(40, 120); } diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 0f14169..73ff307 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -598,6 +598,9 @@ void MainWindow::createToolBars() { m_playbackToolBar = addToolBar(tr("Playback")); m_playbackToolBar->setObjectName("PlaybackToolBar"); + // larger live-control targets: GO/STOP/NEXT/PREV/PANIC are hit under pressure + m_playbackToolBar->setIconSize(QSize(28, 28)); + m_playbackToolBar->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); m_playbackToolBar->addAction(m_goAction); m_playbackToolBar->addAction(m_stopAction); m_playbackToolBar->addSeparator(); From 71437e77ebfe75a218e89972752c6dd45ef2ad14 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 16:48:59 -0400 Subject: [PATCH 51/92] feat: tabbed remote dialog with field gating, high-contrast booth theme --- src/main.cpp | 6 +++-- src/ui/PositionPanel.cpp | 2 +- src/ui/RemoteControlDialog.cpp | 45 +++++++++++++++++++++++++++++----- src/ui/SettingsDialog.cpp | 20 +++++++++++++++ src/ui/SettingsDialog.h | 2 ++ src/ui/theme/Theme.cpp | 31 +++++++++++++++++++---- src/ui/theme/Theme.h | 3 ++- 7 files changed, 94 insertions(+), 15 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 4098b36..fcd1142 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,6 +3,7 @@ #include "ui/WelcomeDialog.h" #include "ui/theme/Theme.h" #include +#include #include #include #include @@ -41,8 +42,9 @@ int main(int argc, char* argv[]) { darkPalette.setColor(QPalette::Disabled, QPalette::WindowText, QColor(0x66, 0x66, 0x66)); QApplication::setPalette(darkPalette); - // apply global stylesheet - qtApp.setStyleSheet(OpenMix::Theme::globalStylesheet()); + // apply global stylesheet (booth-friendly high-contrast variant is opt-in) + const bool highContrast = QSettings().value("highContrast", false).toBool(); + qtApp.setStyleSheet(OpenMix::Theme::globalStylesheet(highContrast)); // create app instance OpenMix::Application app; diff --git a/src/ui/PositionPanel.cpp b/src/ui/PositionPanel.cpp index efbaa60..c7136b9 100644 --- a/src/ui/PositionPanel.cpp +++ b/src/ui/PositionPanel.cpp @@ -109,7 +109,7 @@ void PositionPanel::setupUi() { m_panSpin->setSingleStep(0.05); m_panSpin->setToolTip(tr("Pan: -1 full left, 0 center, +1 full right")); connect(m_panSpin, &QDoubleSpinBox::editingFinished, this, &PositionPanel::onFieldsEdited); - form->addRow(tr("Pan:"), m_panSpin); + form->addRow(tr("Pan (L / R):"), m_panSpin); m_busesEdit = new QLineEdit(editorGroup); m_busesEdit->setPlaceholderText(tr("e.g. 1, 3, 5")); diff --git a/src/ui/RemoteControlDialog.cpp b/src/ui/RemoteControlDialog.cpp index 532e007..36602e0 100644 --- a/src/ui/RemoteControlDialog.cpp +++ b/src/ui/RemoteControlDialog.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include namespace OpenMix { @@ -27,6 +28,21 @@ RemoteControlDialog::RemoteControlDialog(Application* app, QWidget* parent) void RemoteControlDialog::setupUi() { auto* layout = new QVBoxLayout(this); + auto* tabs = new QTabWidget(this); + + // gate a group's config fields on its enable checkbox + auto gate = [](QCheckBox* box, QList fields) { + auto apply = [fields](bool on) { + for (QWidget* w : fields) + w->setEnabled(on); + }; + apply(box->isChecked()); + QObject::connect(box, &QCheckBox::toggled, box, [apply](bool on) { apply(on); }); + }; + + // ==== Tab 1: primary remotes (MSC + inbound OSC) ======================= + auto* primaryPage = new QWidget(tabs); + auto* primaryLayout = new QVBoxLayout(primaryPage); // --- MIDI Show Control ------------------------------------------------- auto* mscBox = new QGroupBox(tr("MIDI Show Control (MSC)"), this); @@ -41,7 +57,8 @@ void RemoteControlDialog::setupUi() { tr("MSC rides on the MIDI input device set in Settings > MIDI Controller."), mscBox); mscNote->setWordWrap(true); mscForm->addRow(mscNote); - layout->addWidget(mscBox); + gate(m_mscEnabled, {m_mscDeviceId}); + primaryLayout->addWidget(mscBox); // --- inbound OSC ------------------------------------------------------- auto* oscBox = new QGroupBox(tr("Inbound OSC Remote"), this); @@ -57,7 +74,14 @@ void RemoteControlDialog::setupUi() { tr("Accepts /go, /stop, /next, /prev, /cue/goto and /ctrl/fadeall."), oscBox); oscNote->setWordWrap(true); oscForm->addRow(oscNote); - layout->addWidget(oscBox); + gate(m_oscEnabled, {m_oscPort}); + primaryLayout->addWidget(oscBox); + primaryLayout->addStretch(); + tabs->addTab(primaryPage, tr("Primary")); + + // ==== Tab 2: DAW & playback integration ================================ + auto* dawPage = new QWidget(tabs); + auto* dawLayout = new QVBoxLayout(dawPage); // --- outbound QLab ----------------------------------------------------- auto* qlabBox = new QGroupBox(tr("QLab / DAW Remote (outbound)"), this); @@ -78,7 +102,8 @@ void RemoteControlDialog::setupUi() { qlabForm->addRow(tr("Port:"), m_qlabPort); qlabForm->addRow(tr("Pre-roll:"), m_qlabPreRoll); qlabForm->addRow(tr("Workspace:"), m_qlabWorkspace); - layout->addWidget(qlabBox); + gate(m_qlabEnabled, {m_qlabHost, m_qlabPort, m_qlabPreRoll, m_qlabWorkspace}); + dawLayout->addWidget(qlabBox); // --- REAPER virtual sound check --------------------------------------- auto* reaperBox = new QGroupBox(tr("REAPER (Virtual Sound Check)"), this); @@ -103,7 +128,9 @@ void RemoteControlDialog::setupUi() { reaperForm->addRow(tr("Marker pre-roll:"), m_reaperPreRoll); reaperForm->addRow(m_reaperAutoDetect); reaperForm->addRow(tr("Listen port:"), m_reaperListenPort); - layout->addWidget(reaperBox); + gate(m_reaperEnabled, {m_reaperHost, m_reaperPort, m_reaperRecord, m_reaperPreRoll, + m_reaperAutoDetect, m_reaperListenPort}); + dawLayout->addWidget(reaperBox); // --- Cue Player (cpsound) --------------------------------------------- auto* cpBox = new QGroupBox(tr("Cue Player (cpsound)"), this); @@ -116,7 +143,8 @@ void RemoteControlDialog::setupUi() { cpForm->addRow(m_cuePlayerEnabled); cpForm->addRow(tr("Host:"), m_cuePlayerHost); cpForm->addRow(tr("Port:"), m_cuePlayerPort); - layout->addWidget(cpBox); + gate(m_cuePlayerEnabled, {m_cuePlayerHost, m_cuePlayerPort}); + dawLayout->addWidget(cpBox); // --- Show Cue System (SCS) -------------------------------------------- auto* scsBox = new QGroupBox(tr("Show Cue System (SCS)"), this); @@ -132,7 +160,12 @@ void RemoteControlDialog::setupUi() { scsForm->addRow(tr("Host:"), m_scsHost); scsForm->addRow(tr("Port:"), m_scsPort); scsForm->addRow(tr("Password:"), m_scsPassword); - layout->addWidget(scsBox); + gate(m_scsEnabled, {m_scsHost, m_scsPort, m_scsPassword}); + dawLayout->addWidget(scsBox); + dawLayout->addStretch(); + tabs->addTab(dawPage, tr("DAW & Playback")); + + layout->addWidget(tabs); auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); connect(buttons, &QDialogButtonBox::accepted, this, &RemoteControlDialog::accept); diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp index 213b97d..f644a14 100644 --- a/src/ui/SettingsDialog.cpp +++ b/src/ui/SettingsDialog.cpp @@ -5,7 +5,9 @@ #include "core/PlaybackEngine.h" #include "core/ScribbleController.h" #include "core/Show.h" +#include "theme/Theme.h" +#include #include #include #include @@ -98,6 +100,14 @@ void SettingsDialog::setupUi() { qlabForm->addRow(m_qlabSuppressBack); layout->addWidget(qlabBox); + // --- display ----------------------------------------------------------- + auto* displayBox = new QGroupBox(tr("Display"), this); + auto* displayForm = new QFormLayout(displayBox); + m_highContrast = new QCheckBox(tr("High-contrast mode (brighter text and borders for the booth)"), + displayBox); + displayForm->addRow(m_highContrast); + layout->addWidget(displayBox); + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); connect(buttons, &QDialogButtonBox::accepted, this, &SettingsDialog::accept); connect(buttons, &QDialogButtonBox::rejected, this, &SettingsDialog::reject); @@ -129,6 +139,8 @@ void SettingsDialog::loadValues() { m_qlabPasscode->setText(qlab->passcode()); m_qlabSuppressBack->setChecked(qlab->suppressBack()); } + + m_highContrast->setChecked(QSettings().value("highContrast", false).toBool()); } void SettingsDialog::applyValues() { @@ -181,6 +193,14 @@ void SettingsDialog::applyValues() { qlab->setSuppressBack(m_qlabSuppressBack->isChecked()); qlab->saveToSettings(); } + + // high-contrast theme: persist and re-skin the running app immediately + { + const bool hc = m_highContrast->isChecked(); + QSettings().setValue("highContrast", hc); + if (auto* app = qobject_cast(QApplication::instance())) + app->setStyleSheet(Theme::globalStylesheet(hc)); + } } void SettingsDialog::accept() { diff --git a/src/ui/SettingsDialog.h b/src/ui/SettingsDialog.h index c2a05fd..bb97ee1 100644 --- a/src/ui/SettingsDialog.h +++ b/src/ui/SettingsDialog.h @@ -56,6 +56,8 @@ class SettingsDialog : public QDialog { // QLab remote QLineEdit* m_qlabPasscode = nullptr; QCheckBox* m_qlabSuppressBack = nullptr; + + QCheckBox* m_highContrast = nullptr; }; } // namespace OpenMix diff --git a/src/ui/theme/Theme.cpp b/src/ui/theme/Theme.cpp index 46ba389..3d81c67 100644 --- a/src/ui/theme/Theme.cpp +++ b/src/ui/theme/Theme.cpp @@ -6,13 +6,29 @@ namespace OpenMix { namespace Theme { -QString globalStylesheet() { +// Booth-friendly overrides: pure-white text, brighter borders and gridlines so +// the UI stays legible from a distance in a darkened room. +static QString highContrastOverrides() { + return QString(R"QSS( +QWidget { color: #ffffff; } +QLabel { color: #ffffff; } +QGroupBox { border: 1px solid #8a93a3; } +QGroupBox::title { color: #ffffff; } +QTableView { gridline-color: #8a93a3; color: #ffffff; } +QHeaderView::section { color: #ffffff; } +QPushButton, QToolButton { border: 1px solid #8a93a3; } +QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox { border: 1px solid #8a93a3; color: #ffffff; } +QMenu, QMenuBar { color: #ffffff; } +)QSS"); +} + +QString globalStylesheet(bool highContrast) { + QString base; QFile styleFile(":/styles/main.qss"); if (styleFile.open(QIODevice::ReadOnly | QIODevice::Text)) { - return QString::fromUtf8(styleFile.readAll()); - } - - return QString(R"QSS( + base = QString::fromUtf8(styleFile.readAll()); + } else { + base = QString(R"QSS( * { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; font-size: 12px; @@ -24,6 +40,11 @@ QWidget { color: #f0f1f3; } )QSS"); + } + + if (highContrast) + base += highContrastOverrides(); + return base; } QColor color(const char* themeColor) { return QColor(themeColor); } diff --git a/src/ui/theme/Theme.h b/src/ui/theme/Theme.h index a7b8d6e..6fdbd2b 100644 --- a/src/ui/theme/Theme.h +++ b/src/ui/theme/Theme.h @@ -150,7 +150,8 @@ constexpr int IconL = 20; constexpr int IconXL = 24; } // namespace Size -QString globalStylesheet(); +// base dark theme; pass highContrast=true for a brighter booth-friendly variant +QString globalStylesheet(bool highContrast = false); QColor color(const char* themeColor); From 9d03b707840040d506e0786897470277c4e7c9cb Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 17:20:55 -0400 Subject: [PATCH 52/92] feat: collapsible editor sections (collapsed by default), fix standby row color cell --- src/ui/CueEditor.cpp | 26 ++++++++++++++++++++++++++ src/ui/CueListView.cpp | 7 +++++++ 2 files changed, 33 insertions(+) diff --git a/src/ui/CueEditor.cpp b/src/ui/CueEditor.cpp index b8b8c22..e6510ec 100644 --- a/src/ui/CueEditor.cpp +++ b/src/ui/CueEditor.cpp @@ -55,6 +55,22 @@ CueEditor::CueEditor(Application* app, QWidget* parent) : QWidget(parent), m_app updateGangsUI(); } +namespace { +// Turn a group box into a collapsible section: the title checkbox hides the +// body so a long editor pane stays compact. Secondary sections start collapsed. +void makeCollapsible(QGroupBox* box, bool collapsed) { + box->setCheckable(true); + box->setChecked(!collapsed); + auto applyBody = [box](bool open) { + const auto kids = box->findChildren(QString(), Qt::FindDirectChildrenOnly); + for (QWidget* w : kids) + w->setVisible(open); + }; + applyBody(!collapsed); + QObject::connect(box, &QGroupBox::toggled, box, [applyBody](bool on) { applyBody(on); }); +} +} // namespace + void CueEditor::setupUi() { setMinimumWidth(280); setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Expanding); @@ -98,6 +114,7 @@ void CueEditor::setupUi() { m_skipCheck = new QCheckBox(tr("Skip on standby advance"), this); basicLayout->addRow(tr("Skip:"), m_skipCheck); + makeCollapsible(basicGroup, false); // primary info stays open m_mainLayout->addWidget(basicGroup); // timing group @@ -115,6 +132,7 @@ void CueEditor::setupUi() { m_autoFollowDelaySpin->setEnabled(false); timingLayout->addRow(tr("Delay:"), m_autoFollowDelaySpin); + makeCollapsible(timingGroup, true); m_mainLayout->addWidget(timingGroup); // fade transition group @@ -136,10 +154,12 @@ void CueEditor::setupUi() { m_fadeCurveCombo->addItem(tr("Ease Out"), static_cast(FadeCurve::EaseOut)); fadeLayout->addRow(tr("Curve:"), m_fadeCurveCombo); + makeCollapsible(fadeGroup, true); m_mainLayout->addWidget(fadeGroup); // DCA targeting section createDCATargetingSection(); + makeCollapsible(m_dcaTargetingGroup, true); m_mainLayout->addWidget(m_dcaTargetingGroup); // DCA overrides section @@ -199,14 +219,17 @@ void CueEditor::setupUi() { overridesContentLayout->addStretch(); overridesLayout->addWidget(overridesContent); + makeCollapsible(m_dcaOverridesGroup, true); m_mainLayout->addWidget(m_dcaOverridesGroup); // per-channel actor profile + level createChannelProfilesSection(); + makeCollapsible(m_channelProfilesGroup, true); m_mainLayout->addWidget(m_channelProfilesGroup); // per-FX-unit mutes + console snippets createFxMutesSection(); + makeCollapsible(m_fxMutesGroup, true); m_mainLayout->addWidget(m_fxMutesGroup); // linked QLab (DAW remote) cue @@ -216,6 +239,7 @@ void CueEditor::setupUi() { m_qLabCueEdit->setPlaceholderText(tr("QLab cue number / id")); m_qLabCueEdit->setToolTip(tr("Fire this QLab cue when the OpenMix cue executes")); qlabLayout->addRow(tr("QLab cue:"), m_qLabCueEdit); + makeCollapsible(qlabGroup, true); m_mainLayout->addWidget(qlabGroup); // L/R gangs (show-level) + soundcheck (check) mode toggle @@ -228,6 +252,7 @@ void CueEditor::setupUi() { m_checkModeCheck = new QCheckBox(tr("Soundcheck mode (GO holds on current cue)"), rehearsalGroup); rehearsalLayout->addRow(tr("Rehearsal:"), m_checkModeCheck); + makeCollapsible(rehearsalGroup, true); m_mainLayout->addWidget(rehearsalGroup); // notes group @@ -239,6 +264,7 @@ void CueEditor::setupUi() { m_notesEdit->setMaximumHeight(120); notesLayout->addWidget(m_notesEdit); + makeCollapsible(notesGroup, true); m_mainLayout->addWidget(notesGroup); m_mainLayout->addStretch(); diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index 0689269..2c9db7d 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -138,6 +138,13 @@ void CueListView::setupDelegates() { m_tableView->setItemDelegateForColumn(CueTableModel::ColGroup, m_textDelegate); m_tableView->setItemDelegateForColumn(CueTableModel::ColTags, m_textDelegate); m_tableView->setItemDelegateForColumn(CueTableModel::ColNotes, m_textDelegate); + // remaining columns: honor the row's standby/current background instead of + // painting a selection block over it + m_tableView->setItemDelegateForColumn(CueTableModel::ColColor, m_textDelegate); + m_tableView->setItemDelegateForColumn(CueTableModel::ColDca, m_textDelegate); + m_tableView->setItemDelegateForColumn(CueTableModel::ColPosition, m_textDelegate); + m_tableView->setItemDelegateForColumn(CueTableModel::ColFx, m_textDelegate); + m_tableView->setItemDelegateForColumn(CueTableModel::ColFade, m_textDelegate); // connect tab navigation connect(m_numberDelegate, &CueNumberDelegate::tabNavigationRequested, this, From 088ee3a8969dabf16d50b999ed79ae9d8830833d Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 17:34:13 -0400 Subject: [PATCH 53/92] feat: chevron collapsible sections (no frames), pad live-control buttons --- CMakeLists.txt | 2 + src/ui/CollapsibleSection.cpp | 49 ++++++++++++++++++++++ src/ui/CollapsibleSection.h | 30 +++++++++++++ src/ui/CueEditor.cpp | 79 +++++++++++++++-------------------- src/ui/CueEditor.h | 9 ++-- src/ui/MainWindow.cpp | 3 ++ 6 files changed, 122 insertions(+), 50 deletions(-) create mode 100644 src/ui/CollapsibleSection.cpp create mode 100644 src/ui/CollapsibleSection.h diff --git a/CMakeLists.txt b/CMakeLists.txt index a9fcf71..357523c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -116,6 +116,7 @@ set(SOURCES src/ui/MainWindow.cpp src/ui/CueListView.cpp src/ui/CueEditor.cpp + src/ui/CollapsibleSection.cpp src/ui/ConnectionPanel.cpp src/ui/CueTableModel.cpp src/ui/CueFilterProxyModel.cpp @@ -232,6 +233,7 @@ set(HEADERS src/ui/MainWindow.h src/ui/CueListView.h src/ui/CueEditor.h + src/ui/CollapsibleSection.h src/ui/ConnectionPanel.h src/ui/CueTableModel.h src/ui/CueFilterProxyModel.h diff --git a/src/ui/CollapsibleSection.cpp b/src/ui/CollapsibleSection.cpp new file mode 100644 index 0000000..36f5e06 --- /dev/null +++ b/src/ui/CollapsibleSection.cpp @@ -0,0 +1,49 @@ +#include "CollapsibleSection.h" + +#include +#include +#include + +namespace OpenMix { + +CollapsibleSection::CollapsibleSection(const QString& title, QWidget* parent) : QWidget(parent) { + auto* outer = new QVBoxLayout(this); + outer->setContentsMargins(0, 0, 0, 0); + outer->setSpacing(0); + + m_toggle = new QToolButton(this); + m_toggle->setText(title); + m_toggle->setCheckable(true); + m_toggle->setChecked(false); + m_toggle->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + m_toggle->setArrowType(Qt::RightArrow); + m_toggle->setAutoRaise(true); + m_toggle->setFocusPolicy(Qt::TabFocus); + m_toggle->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + m_toggle->setCursor(Qt::PointingHandCursor); + m_toggle->setStyleSheet( + "QToolButton { border: none; font-weight: bold; text-align: left; padding: 6px 2px; }"); + + m_body = new QWidget(this); + m_body->setVisible(false); + + outer->addWidget(m_toggle); + outer->addWidget(m_body); + + connect(m_toggle, &QToolButton::toggled, this, [this](bool on) { + m_body->setVisible(on); + m_toggle->setArrowType(on ? Qt::DownArrow : Qt::RightArrow); + }); +} + +void CollapsibleSection::setContentLayout(QLayout* layout) { + if (QLayout* old = m_body->layout()) + delete old; + m_body->setLayout(layout); +} + +void CollapsibleSection::setExpanded(bool expanded) { m_toggle->setChecked(expanded); } + +bool CollapsibleSection::isExpanded() const { return m_toggle->isChecked(); } + +} // namespace OpenMix diff --git a/src/ui/CollapsibleSection.h b/src/ui/CollapsibleSection.h new file mode 100644 index 0000000..5ef6eba --- /dev/null +++ b/src/ui/CollapsibleSection.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +class QToolButton; +class QLayout; + +namespace OpenMix { + +// A titled section that collapses to a single chevron header row. Unlike a +// checkable QGroupBox it draws no frame when collapsed and never enables or +// disables its body as a side effect of toggling. +class CollapsibleSection : public QWidget { + Q_OBJECT + + public: + explicit CollapsibleSection(const QString& title, QWidget* parent = nullptr); + + // takes ownership of the layout and installs it on the body + void setContentLayout(QLayout* layout); + + void setExpanded(bool expanded); + [[nodiscard]] bool isExpanded() const; + + private: + QToolButton* m_toggle; + QWidget* m_body; +}; + +} // namespace OpenMix diff --git a/src/ui/CueEditor.cpp b/src/ui/CueEditor.cpp index e6510ec..6b5cad9 100644 --- a/src/ui/CueEditor.cpp +++ b/src/ui/CueEditor.cpp @@ -1,4 +1,5 @@ #include "CueEditor.h" +#include "CollapsibleSection.h" #include "app/Application.h" #include "core/Actor.h" #include "core/ActorProfileLibrary.h" @@ -55,22 +56,6 @@ CueEditor::CueEditor(Application* app, QWidget* parent) : QWidget(parent), m_app updateGangsUI(); } -namespace { -// Turn a group box into a collapsible section: the title checkbox hides the -// body so a long editor pane stays compact. Secondary sections start collapsed. -void makeCollapsible(QGroupBox* box, bool collapsed) { - box->setCheckable(true); - box->setChecked(!collapsed); - auto applyBody = [box](bool open) { - const auto kids = box->findChildren(QString(), Qt::FindDirectChildrenOnly); - for (QWidget* w : kids) - w->setVisible(open); - }; - applyBody(!collapsed); - QObject::connect(box, &QGroupBox::toggled, box, [applyBody](bool on) { applyBody(on); }); -} -} // namespace - void CueEditor::setupUi() { setMinimumWidth(280); setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Expanding); @@ -81,8 +66,8 @@ void CueEditor::setupUi() { m_mainLayout = new QVBoxLayout(content); // basic properties group - QGroupBox* basicGroup = new QGroupBox(tr("Cue Properties"), this); - QFormLayout* basicLayout = new QFormLayout(basicGroup); + auto* basicGroup = new CollapsibleSection(tr("Cue Properties"), this); + QFormLayout* basicLayout = new QFormLayout(); m_numberSpin = new QDoubleSpinBox(this); m_numberSpin->setRange(0.0, 9999.9); @@ -114,12 +99,13 @@ void CueEditor::setupUi() { m_skipCheck = new QCheckBox(tr("Skip on standby advance"), this); basicLayout->addRow(tr("Skip:"), m_skipCheck); - makeCollapsible(basicGroup, false); // primary info stays open + basicGroup->setContentLayout(basicLayout); + basicGroup->setExpanded(true); // primary info stays open m_mainLayout->addWidget(basicGroup); // timing group - QGroupBox* timingGroup = new QGroupBox(tr("Auto-Follow"), this); - QFormLayout* timingLayout = new QFormLayout(timingGroup); + auto* timingGroup = new CollapsibleSection(tr("Auto-Follow"), this); + QFormLayout* timingLayout = new QFormLayout(); m_autoFollowCheck = new QCheckBox(tr("Enable"), this); timingLayout->addRow(tr("Auto-follow:"), m_autoFollowCheck); @@ -132,12 +118,12 @@ void CueEditor::setupUi() { m_autoFollowDelaySpin->setEnabled(false); timingLayout->addRow(tr("Delay:"), m_autoFollowDelaySpin); - makeCollapsible(timingGroup, true); + timingGroup->setContentLayout(timingLayout); m_mainLayout->addWidget(timingGroup); // fade transition group - QGroupBox* fadeGroup = new QGroupBox(tr("Fade Transition"), this); - QFormLayout* fadeLayout = new QFormLayout(fadeGroup); + auto* fadeGroup = new CollapsibleSection(tr("Fade Transition"), this); + QFormLayout* fadeLayout = new QFormLayout(); m_fadeTimeSpin = new QDoubleSpinBox(this); m_fadeTimeSpin->setRange(0.0, 600.0); @@ -154,17 +140,16 @@ void CueEditor::setupUi() { m_fadeCurveCombo->addItem(tr("Ease Out"), static_cast(FadeCurve::EaseOut)); fadeLayout->addRow(tr("Curve:"), m_fadeCurveCombo); - makeCollapsible(fadeGroup, true); + fadeGroup->setContentLayout(fadeLayout); m_mainLayout->addWidget(fadeGroup); // DCA targeting section createDCATargetingSection(); - makeCollapsible(m_dcaTargetingGroup, true); m_mainLayout->addWidget(m_dcaTargetingGroup); // DCA overrides section - m_dcaOverridesGroup = new QGroupBox(tr("DCA Overrides (Mute/Label)"), this); - QVBoxLayout* overridesLayout = new QVBoxLayout(m_dcaOverridesGroup); + m_dcaOverridesGroup = new CollapsibleSection(tr("DCA Overrides (Mute/Label)"), this); + QVBoxLayout* overridesLayout = new QVBoxLayout(); // no inner scroll here: the whole editor is already scrollable, so let the // overrides expand inline rather than nesting a second scrollbar @@ -219,32 +204,30 @@ void CueEditor::setupUi() { overridesContentLayout->addStretch(); overridesLayout->addWidget(overridesContent); - makeCollapsible(m_dcaOverridesGroup, true); + m_dcaOverridesGroup->setContentLayout(overridesLayout); m_mainLayout->addWidget(m_dcaOverridesGroup); // per-channel actor profile + level createChannelProfilesSection(); - makeCollapsible(m_channelProfilesGroup, true); m_mainLayout->addWidget(m_channelProfilesGroup); // per-FX-unit mutes + console snippets createFxMutesSection(); - makeCollapsible(m_fxMutesGroup, true); m_mainLayout->addWidget(m_fxMutesGroup); // linked QLab (DAW remote) cue - QGroupBox* qlabGroup = new QGroupBox(tr("QLab / DAW Remote"), this); - QFormLayout* qlabLayout = new QFormLayout(qlabGroup); + auto* qlabGroup = new CollapsibleSection(tr("QLab / DAW Remote"), this); + QFormLayout* qlabLayout = new QFormLayout(); m_qLabCueEdit = new QLineEdit(this); m_qLabCueEdit->setPlaceholderText(tr("QLab cue number / id")); m_qLabCueEdit->setToolTip(tr("Fire this QLab cue when the OpenMix cue executes")); qlabLayout->addRow(tr("QLab cue:"), m_qLabCueEdit); - makeCollapsible(qlabGroup, true); + qlabGroup->setContentLayout(qlabLayout); m_mainLayout->addWidget(qlabGroup); // L/R gangs (show-level) + soundcheck (check) mode toggle - QGroupBox* rehearsalGroup = new QGroupBox(tr("Gangs && Rehearsal"), this); - QFormLayout* rehearsalLayout = new QFormLayout(rehearsalGroup); + auto* rehearsalGroup = new CollapsibleSection(tr("Gangs && Rehearsal"), this); + QFormLayout* rehearsalLayout = new QFormLayout(); m_gangEdit = new QLineEdit(rehearsalGroup); m_gangEdit->setPlaceholderText(tr("L/R pairs, e.g. 1-2, 3-4")); m_gangEdit->setToolTip(tr("Ganged channel pairs; a level on one mirrors to its partner")); @@ -252,19 +235,19 @@ void CueEditor::setupUi() { m_checkModeCheck = new QCheckBox(tr("Soundcheck mode (GO holds on current cue)"), rehearsalGroup); rehearsalLayout->addRow(tr("Rehearsal:"), m_checkModeCheck); - makeCollapsible(rehearsalGroup, true); + rehearsalGroup->setContentLayout(rehearsalLayout); m_mainLayout->addWidget(rehearsalGroup); // notes group - QGroupBox* notesGroup = new QGroupBox(tr("Notes"), this); - QVBoxLayout* notesLayout = new QVBoxLayout(notesGroup); + auto* notesGroup = new CollapsibleSection(tr("Notes"), this); + QVBoxLayout* notesLayout = new QVBoxLayout(); m_notesEdit = new QTextEdit(this); m_notesEdit->setPlaceholderText(tr("Enter cue notes...")); m_notesEdit->setMaximumHeight(120); notesLayout->addWidget(m_notesEdit); - makeCollapsible(notesGroup, true); + notesGroup->setContentLayout(notesLayout); m_mainLayout->addWidget(notesGroup); m_mainLayout->addStretch(); @@ -305,8 +288,8 @@ void CueEditor::setupUi() { } void CueEditor::createFxMutesSection() { - m_fxMutesGroup = new QGroupBox(tr("FX Mutes && Snippets"), this); - QVBoxLayout* layout = new QVBoxLayout(m_fxMutesGroup); + m_fxMutesGroup = new CollapsibleSection(tr("FX Mutes && Snippets"), this); + QVBoxLayout* layout = new QVBoxLayout(); layout->setContentsMargins(4, 4, 4, 4); QFormLayout* snippetForm = new QFormLayout(); @@ -343,11 +326,12 @@ void CueEditor::createFxMutesSection() { m_fxMuteWidgets.append(widgets); } layout->addLayout(grid); + m_fxMutesGroup->setContentLayout(layout); } void CueEditor::createChannelProfilesSection() { - m_channelProfilesGroup = new QGroupBox(tr("Channel Profiles && Levels"), this); - QVBoxLayout* layout = new QVBoxLayout(m_channelProfilesGroup); + m_channelProfilesGroup = new CollapsibleSection(tr("Channel Profiles && Levels"), this); + QVBoxLayout* layout = new QVBoxLayout(); layout->setContentsMargins(4, 4, 4, 4); QLabel* hint = new QLabel( @@ -366,6 +350,7 @@ void CueEditor::createChannelProfilesSection() { m_channelTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); m_channelTable->setMaximumHeight(340); layout->addWidget(m_channelTable); + m_channelProfilesGroup->setContentLayout(layout); } void CueEditor::addBottomWidget(QWidget* widget) { @@ -375,8 +360,8 @@ void CueEditor::addBottomWidget(QWidget* widget) { } void CueEditor::createDCATargetingSection() { - m_dcaTargetingGroup = new QGroupBox(tr("Target DCAs"), this); - QVBoxLayout* layout = new QVBoxLayout(m_dcaTargetingGroup); + m_dcaTargetingGroup = new CollapsibleSection(tr("Target DCAs"), this); + QVBoxLayout* layout = new QVBoxLayout(); m_targetAllDCAsCheck = new QCheckBox(tr("Target All DCAs"), this); m_targetAllDCAsCheck->setChecked(true); @@ -423,6 +408,8 @@ void CueEditor::createDCATargetingSection() { } onTargetedDCAsChanged(); }); + + m_dcaTargetingGroup->setContentLayout(layout); } bool CueEditor::hasFocus() const { return m_nameEdit->hasFocus() || m_notesEdit->hasFocus(); } diff --git a/src/ui/CueEditor.h b/src/ui/CueEditor.h index 7ffd1ac..2f575d9 100644 --- a/src/ui/CueEditor.h +++ b/src/ui/CueEditor.h @@ -21,6 +21,7 @@ namespace OpenMix { class Application; class ActorProfileLibrary; class Cue; +class CollapsibleSection; class CueEditor : public QWidget { Q_OBJECT @@ -115,7 +116,7 @@ class CueEditor : public QWidget { QCheckBox* enable; QCheckBox* muted; }; - QGroupBox* m_fxMutesGroup = nullptr; + CollapsibleSection* m_fxMutesGroup = nullptr; QVector m_fxMuteWidgets; // L/R gangs (show-level) + soundcheck (check) mode toggle @@ -124,16 +125,16 @@ class CueEditor : public QWidget { // per-channel actor profile slot + level ActorProfileLibrary* m_actorLibrary = nullptr; - QGroupBox* m_channelProfilesGroup = nullptr; + CollapsibleSection* m_channelProfilesGroup = nullptr; QTableWidget* m_channelTable = nullptr; // DCA targeting widgets - QGroupBox* m_dcaTargetingGroup; + CollapsibleSection* m_dcaTargetingGroup = nullptr; QCheckBox* m_targetAllDCAsCheck; QVector m_dcaTargetChecks; // DCA overrides (mute/label per DCA) - QGroupBox* m_dcaOverridesGroup; + CollapsibleSection* m_dcaOverridesGroup = nullptr; struct DCAOverrideWidgets { QCheckBox* enableMute; QCheckBox* muteValue; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 73ff307..363ba58 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -601,6 +601,9 @@ void MainWindow::createToolBars() { // larger live-control targets: GO/STOP/NEXT/PREV/PANIC are hit under pressure m_playbackToolBar->setIconSize(QSize(28, 28)); m_playbackToolBar->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); + // uniform horizontal padding so short labels (GO/Stop) match the wider ones + m_playbackToolBar->setStyleSheet( + "QToolButton { min-width: 68px; padding-left: 10px; padding-right: 10px; }"); m_playbackToolBar->addAction(m_goAction); m_playbackToolBar->addAction(m_stopAction); m_playbackToolBar->addSeparator(); From e475ad4ad8f6d40527ae0479377b81964d435229 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 17:57:24 -0400 Subject: [PATCH 54/92] fix: reduce live-control button padding --- src/ui/MainWindow.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 363ba58..b08c9ca 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -601,9 +601,9 @@ void MainWindow::createToolBars() { // larger live-control targets: GO/STOP/NEXT/PREV/PANIC are hit under pressure m_playbackToolBar->setIconSize(QSize(28, 28)); m_playbackToolBar->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); - // uniform horizontal padding so short labels (GO/Stop) match the wider ones + // modest horizontal padding so short labels (GO/Stop) aren't cramped m_playbackToolBar->setStyleSheet( - "QToolButton { min-width: 68px; padding-left: 10px; padding-right: 10px; }"); + "QToolButton { padding-left: 6px; padding-right: 6px; }"); m_playbackToolBar->addAction(m_goAction); m_playbackToolBar->addAction(m_stopAction); m_playbackToolBar->addSeparator(); From f0b4205dc2e8fd583ed97117a5049afca576595a Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 18:01:50 -0400 Subject: [PATCH 55/92] fix: icon-only GO/Stop transport buttons --- src/ui/MainWindow.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index b08c9ca..07cf621 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -57,6 +57,7 @@ #include #include #include +#include #include #include #include @@ -612,6 +613,12 @@ void MainWindow::createToolBars() { m_playbackToolBar->addSeparator(); m_playbackToolBar->addAction(m_panicAction); + // GO/Stop icons (play/stop) are universal, so drop their text labels + for (QAction* a : {m_goAction, m_stopAction}) { + if (auto* b = qobject_cast(m_playbackToolBar->widgetForAction(a))) + b->setToolButtonStyle(Qt::ToolButtonIconOnly); + } + // type-to-jump: "12.5" sets standby, "12.5G" jumps and fires m_playbackToolBar->addSeparator(); m_playbackToolBar->addWidget(new QLabel(tr(" Jump: "))); From 97ced629e5a44b662f30f61f775d825bfc396370 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 18:07:42 -0400 Subject: [PATCH 56/92] feat: icon-only PANIC, distinct bubble-bar icons, wrap bubble bar to grid --- src/ui/BubbleBar.cpp | 14 ++++++++++---- src/ui/BubbleBar.h | 4 ++-- src/ui/MainWindow.cpp | 11 ++++++----- src/ui/theme/Icons.h | 3 +++ 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/ui/BubbleBar.cpp b/src/ui/BubbleBar.cpp index 7e3b735..32f263c 100644 --- a/src/ui/BubbleBar.cpp +++ b/src/ui/BubbleBar.cpp @@ -2,9 +2,13 @@ #include "BubbleButton.h" #include "theme/Theme.h" -#include +#include #include +namespace { +constexpr int kBubbleColumns = 4; // wrap the icon grid so it fits a narrow pane +} + namespace OpenMix { BubbleBar::BubbleBar(QWidget* parent) : QWidget(parent) { @@ -13,7 +17,7 @@ BubbleBar::BubbleBar(QWidget* parent) : QWidget(parent) { } void BubbleBar::setupUi() { - m_layout = new QHBoxLayout(this); + m_layout = new QGridLayout(this); m_layout->setContentsMargins(Theme::SpacingS, Theme::SpacingS, Theme::SpacingS, Theme::SpacingS); m_layout->setSpacing(Theme::SpacingXS); @@ -26,9 +30,10 @@ BubbleButton* BubbleBar::addButton(const QString& id, const QString& icon, const return m_buttons[id]; } + const int idx = m_buttons.size(); BubbleButton* button = new BubbleButton(icon, tooltip, this); m_buttons[id] = button; - m_layout->addWidget(button); + m_layout->addWidget(button, idx / kBubbleColumns, idx % kBubbleColumns); connect(button, &QPushButton::clicked, [this, id](bool checked) { emit buttonClicked(id, checked); }); @@ -42,9 +47,10 @@ BubbleButton* BubbleBar::addButton(const QString& id, const QIcon& icon, const Q return m_buttons[id]; } + const int idx = m_buttons.size(); BubbleButton* button = new BubbleButton(icon, tooltip, this); m_buttons[id] = button; - m_layout->addWidget(button); + m_layout->addWidget(button, idx / kBubbleColumns, idx % kBubbleColumns); connect(button, &QPushButton::clicked, [this, id](bool checked) { emit buttonClicked(id, checked); }); diff --git a/src/ui/BubbleBar.h b/src/ui/BubbleBar.h index dd213b1..07a913f 100644 --- a/src/ui/BubbleBar.h +++ b/src/ui/BubbleBar.h @@ -4,7 +4,7 @@ #include #include -class QHBoxLayout; +class QGridLayout; namespace OpenMix { @@ -33,7 +33,7 @@ class BubbleBar : public QWidget { void setupUi(); void updatePosition(); - QHBoxLayout* m_layout = nullptr; + QGridLayout* m_layout = nullptr; QMap m_buttons; }; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 07cf621..52a3c20 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -613,8 +613,9 @@ void MainWindow::createToolBars() { m_playbackToolBar->addSeparator(); m_playbackToolBar->addAction(m_panicAction); - // GO/Stop icons (play/stop) are universal, so drop their text labels - for (QAction* a : {m_goAction, m_stopAction}) { + // GO/Stop/PANIC icons are self-explanatory; drop their labels and rely on + // the tooltips set on each action for hover discovery + for (QAction* a : {m_goAction, m_stopAction, m_panicAction}) { if (auto* b = qobject_cast(m_playbackToolBar->widgetForAction(a))) b->setToolButtonStyle(Qt::ToolButtonIconOnly); } @@ -769,9 +770,9 @@ void MainWindow::createBubbleBar() { m_bubbleBar->addButton("connection", Icons::network(), tr("Connection (F7)")); m_bubbleBar->addButton("actors", Icons::actorSetup(), tr("Actor Setup (F9)")); m_bubbleBar->addButton("ensembles", Icons::actor(), tr("Ensembles (F10)")); - m_bubbleBar->addButton("positions", Icons::sliders(), tr("Positions (F11)")); - m_bubbleBar->addButton("timecode", Icons::sliders(), tr("Timecode Triggers (F12)")); - m_bubbleBar->addButton("activeCueInfo", Icons::sliders(), tr("Active Cue Info")); + m_bubbleBar->addButton("positions", Icons::location(), tr("Positions (F11)")); + m_bubbleBar->addButton("timecode", Icons::clock(), tr("Timecode Triggers (F12)")); + m_bubbleBar->addButton("activeCueInfo", Icons::info(), tr("Active Cue Info")); connect(m_bubbleBar, &BubbleBar::buttonClicked, this, &MainWindow::onBubbleButtonClicked); diff --git a/src/ui/theme/Icons.h b/src/ui/theme/Icons.h index 9b091f2..8a13ad5 100644 --- a/src/ui/theme/Icons.h +++ b/src/ui/theme/Icons.h @@ -53,6 +53,9 @@ inline QIcon mediaNext() { return fromPath(":/qlementine/icons/16/navigation/che inline QIcon warning() { return fromPath(":/qlementine/icons/16/misc/warning.svg"); } inline QIcon sliders() { return fromPath(":/qlementine/icons/16/navigation/sliders-vertical.svg"); } +inline QIcon clock() { return fromPath(":/qlementine/icons/16/misc/clock.svg"); } +inline QIcon location() { return fromPath(":/qlementine/icons/16/misc/location.svg"); } +inline QIcon info() { return fromPath(":/qlementine/icons/16/misc/info.svg"); } inline QIcon audioVolume() { return fromPath(":/qlementine/icons/16/audio/speaker-2.svg"); } inline QIcon network() { return fromPath(":/qlementine/icons/16/hardware/network.svg"); } inline QIcon refresh() { return fromPath(":/qlementine/icons/16/action/refresh.svg"); } From 1406ccfd758c632da6e1c3d2ab2a545a8c5c2e70 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 18:22:55 -0400 Subject: [PATCH 57/92] fix: tighter tooltip padding and faster appearance --- resources/styles/main.qss | 4 ++-- src/main.cpp | 22 +++++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/resources/styles/main.qss b/resources/styles/main.qss index 2ae1fae..6c6210e 100644 --- a/resources/styles/main.qss +++ b/resources/styles/main.qss @@ -818,8 +818,8 @@ QTreeView::branch:open:has-children:has-siblings { QToolTip { background-color: #252830; border: 1px solid #3d4451; - border-radius: 6px; - padding: 6px 10px; + border-radius: 4px; + padding: 2px 6px; color: #f0f1f3; font-size: 11px; } diff --git a/src/main.cpp b/src/main.cpp index fcd1142..0454a29 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,10 +6,29 @@ #include #include #include +#include #include #include #include +namespace OpenMix { +// Show tooltips almost immediately and keep them up longer than the platform +// default, so hovering an icon reveals its label without a long wait. +class FastTooltipStyle : public QProxyStyle { + public: + using QProxyStyle::QProxyStyle; + int styleHint(StyleHint hint, const QStyleOption* option = nullptr, + const QWidget* widget = nullptr, + QStyleHintReturn* returnData = nullptr) const override { + if (hint == SH_ToolTip_WakeUpDelay) + return 200; + if (hint == SH_ToolTip_FallAsleepDelay) + return 0; + return QProxyStyle::styleHint(hint, option, widget, returnData); + } +}; +} // namespace OpenMix + int main(int argc, char* argv[]) { QApplication qtApp(argc, argv); @@ -20,7 +39,8 @@ int main(int argc, char* argv[]) { oclero::qlementine::icons::initializeIconTheme(); - QApplication::setStyle(QStyleFactory::create("Fusion")); + // Fusion base, but show tooltips faster than the platform default + QApplication::setStyle(new OpenMix::FastTooltipStyle(QStyleFactory::create("Fusion"))); // force dark theme on all platforms QPalette darkPalette; From 34ca832677c142f3c9566aef8d3984bf656ee6e9 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 18:27:04 -0400 Subject: [PATCH 58/92] fix: fit bubble-bar icons on a single row --- resources/styles/main.qss | 12 ++++++------ src/ui/BubbleBar.cpp | 8 ++++---- src/ui/BubbleButton.cpp | 8 ++++---- src/ui/theme/Theme.h | 1 + 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/resources/styles/main.qss b/resources/styles/main.qss index 6c6210e..4338866 100644 --- a/resources/styles/main.qss +++ b/resources/styles/main.qss @@ -947,12 +947,12 @@ QWidget#BubbleBar { QPushButton#BubbleButton { background-color: transparent; border: none; - border-radius: 8px; - padding: 8px; - min-width: 36px; - max-width: 36px; - min-height: 36px; - max-height: 36px; + border-radius: 6px; + padding: 4px; + min-width: 30px; + max-width: 30px; + min-height: 30px; + max-height: 30px; color: #9ca3af; } diff --git a/src/ui/BubbleBar.cpp b/src/ui/BubbleBar.cpp index 32f263c..783b0cc 100644 --- a/src/ui/BubbleBar.cpp +++ b/src/ui/BubbleBar.cpp @@ -6,7 +6,7 @@ #include namespace { -constexpr int kBubbleColumns = 4; // wrap the icon grid so it fits a narrow pane +constexpr int kBubbleColumns = 64; // keep the icons on a single row } namespace OpenMix { @@ -18,9 +18,9 @@ BubbleBar::BubbleBar(QWidget* parent) : QWidget(parent) { void BubbleBar::setupUi() { m_layout = new QGridLayout(this); - m_layout->setContentsMargins(Theme::SpacingS, Theme::SpacingS, Theme::SpacingS, - Theme::SpacingS); - m_layout->setSpacing(Theme::SpacingXS); + m_layout->setContentsMargins(Theme::SpacingXS, Theme::SpacingXS, Theme::SpacingXS, + Theme::SpacingXS); + m_layout->setSpacing(Theme::SpacingXXS); setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); } diff --git a/src/ui/BubbleButton.cpp b/src/ui/BubbleButton.cpp index d719802..1ff810d 100644 --- a/src/ui/BubbleButton.cpp +++ b/src/ui/BubbleButton.cpp @@ -13,7 +13,7 @@ BubbleButton::BubbleButton(const QString& icon, const QString& tooltip, QWidget* setObjectName(Theme::ObjectNames::BubbleButton); setToolTip(tooltip); setCheckable(true); - setFixedSize(36, 36); + setFixedSize(30, 30); QFont f = font(); f.setPointSize(16); @@ -27,9 +27,9 @@ BubbleButton::BubbleButton(const QIcon& icon, const QString& tooltip, QWidget* p setObjectName(Theme::ObjectNames::BubbleButton); setToolTip(tooltip); setCheckable(true); - setFixedSize(36, 36); + setFixedSize(30, 30); QPushButton::setIcon(icon); - setIconSize(QSize(20, 20)); + setIconSize(QSize(18, 18)); } void BubbleButton::setBadge(const QString& text) { @@ -60,7 +60,7 @@ void BubbleButton::setButtonIcon(const QIcon& icon) { m_iconText.clear(); setText(QString()); QPushButton::setIcon(icon); - setIconSize(QSize(20, 20)); + setIconSize(QSize(18, 18)); update(); } diff --git a/src/ui/theme/Theme.h b/src/ui/theme/Theme.h index 6fdbd2b..321f406 100644 --- a/src/ui/theme/Theme.h +++ b/src/ui/theme/Theme.h @@ -85,6 +85,7 @@ constexpr int XL = 24; constexpr int XXL = 32; } // namespace Spacing +constexpr int SpacingXXS = Spacing::XXS; constexpr int SpacingXS = Spacing::XS; constexpr int SpacingS = Spacing::S; constexpr int SpacingM = Spacing::M; From 7f3f9a6bf6b3f7ff6542ae245e40f6c04a9434fb Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 18:39:10 -0400 Subject: [PATCH 59/92] feat: center bubble bar and tooltips horizontally --- src/ui/CueEditor.cpp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/ui/CueEditor.cpp b/src/ui/CueEditor.cpp index 6b5cad9..03f5c7d 100644 --- a/src/ui/CueEditor.cpp +++ b/src/ui/CueEditor.cpp @@ -354,9 +354,14 @@ void CueEditor::createChannelProfilesSection() { } void CueEditor::addBottomWidget(QWidget* widget) { - // add below the scroll area so it stays fixed while the sections scroll - if (widget && layout()) - layout()->addWidget(widget); + // add below the scroll area so it stays fixed while the sections scroll, + // horizontally centered + if (widget) { + if (auto* box = qobject_cast(layout())) + box->addWidget(widget, 0, Qt::AlignHCenter); + else if (layout()) + layout()->addWidget(widget); + } } void CueEditor::createDCATargetingSection() { From 97ff4636649e9269439df8a053cb4d865219e6b3 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 18:49:23 -0400 Subject: [PATCH 60/92] fix: center tooltips under widget incl action buttons, icon-only prev/next cue --- src/main.cpp | 42 ++++++++++++++++++++++++++++++++++++++++++ src/ui/MainWindow.cpp | 5 +++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 0454a29..2b11874 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,12 +6,53 @@ #include #include #include +#include +#include +#include #include #include #include +#include +#include #include namespace OpenMix { + +// Show tooltips horizontally centered under the widget (and center multi-line +// text), rather than the platform's offset-to-the-right placement. +class CenteredToolTipFilter : public QObject { + public: + using QObject::QObject; + bool eventFilter(QObject* obj, QEvent* event) override { + if (event->type() == QEvent::ToolTip) { + auto* widget = qobject_cast(obj); + if (widget) { + // toolbar buttons carry the tooltip on their action, not the widget + QString tip = widget->toolTip(); + if (tip.isEmpty()) + if (auto* button = qobject_cast(widget)) + if (QAction* action = button->defaultAction()) + tip = action->toolTip(); + + if (!tip.isEmpty()) { + const QString html = "
" + + tip.toHtmlEscaped().replace("\n", "
") + "
"; + const int boxWidth = + QFontMetrics(QToolTip::font()) + .boundingRect(QRect(0, 0, 600, 1000), Qt::TextWordWrap, tip) + .width() + + 20; // padding + border + const QPoint anchor = + widget->mapToGlobal(QPoint(widget->width() / 2, widget->height() + 2)); + QToolTip::showText(QPoint(anchor.x() - boxWidth / 2, anchor.y()), html, widget); + return true; + } + } + } + return QObject::eventFilter(obj, event); + } +}; + // Show tooltips almost immediately and keep them up longer than the platform // default, so hovering an icon reveals its label without a long wait. class FastTooltipStyle : public QProxyStyle { @@ -41,6 +82,7 @@ int main(int argc, char* argv[]) { // Fusion base, but show tooltips faster than the platform default QApplication::setStyle(new OpenMix::FastTooltipStyle(QStyleFactory::create("Fusion"))); + qtApp.installEventFilter(new OpenMix::CenteredToolTipFilter(&qtApp)); // force dark theme on all platforms QPalette darkPalette; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 52a3c20..bfead0d 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -613,9 +613,10 @@ void MainWindow::createToolBars() { m_playbackToolBar->addSeparator(); m_playbackToolBar->addAction(m_panicAction); - // GO/Stop/PANIC icons are self-explanatory; drop their labels and rely on + // all transport icons are self-explanatory; drop their labels and rely on // the tooltips set on each action for hover discovery - for (QAction* a : {m_goAction, m_stopAction, m_panicAction}) { + for (QAction* a : {m_goAction, m_stopAction, m_previousCueAction, m_nextCueAction, + m_panicAction}) { if (auto* b = qobject_cast(m_playbackToolBar->widgetForAction(a))) b->setToolButtonStyle(Qt::ToolButtonIconOnly); } From 053059b4e1008981c4eeb528c743dfa3538873c7 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 18:57:48 -0400 Subject: [PATCH 61/92] fix: center tooltips on cursor position --- src/main.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 2b11874..52aca42 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -42,9 +42,9 @@ class CenteredToolTipFilter : public QObject { .boundingRect(QRect(0, 0, 600, 1000), Qt::TextWordWrap, tip) .width() + 20; // padding + border - const QPoint anchor = - widget->mapToGlobal(QPoint(widget->width() / 2, widget->height() + 2)); - QToolTip::showText(QPoint(anchor.x() - boxWidth / 2, anchor.y()), html, widget); + const QPoint cursor = static_cast(event)->globalPos(); + QToolTip::showText(QPoint(cursor.x() - boxWidth / 2, cursor.y() + 16), html, + widget); return true; } } From 11b9724f60591e3eae857b2d026d58dbc3375026 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 19:13:25 -0400 Subject: [PATCH 62/92] fix: recenter tooltip on cursor via real width, shorten tooltip text --- src/main.cpp | 15 ++++++++------- src/ui/MainWindow.cpp | 20 ++++++++++---------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 52aca42..acaf4cd 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -37,14 +37,15 @@ class CenteredToolTipFilter : public QObject { if (!tip.isEmpty()) { const QString html = "
" + tip.toHtmlEscaped().replace("\n", "
") + "
"; - const int boxWidth = - QFontMetrics(QToolTip::font()) - .boundingRect(QRect(0, 0, 600, 1000), Qt::TextWordWrap, tip) - .width() + - 20; // padding + border const QPoint cursor = static_cast(event)->globalPos(); - QToolTip::showText(QPoint(cursor.x() - boxWidth / 2, cursor.y() + 16), html, - widget); + QToolTip::showText(QPoint(cursor.x(), cursor.y() + 16), html, widget); + // recenter on the cursor using the tooltip's real rendered width + for (QWidget* tipLabel : QApplication::topLevelWidgets()) { + if (tipLabel->isVisible() && tipLabel->inherits("QTipLabel")) { + tipLabel->move(cursor.x() - tipLabel->width() / 2, tipLabel->y()); + break; + } + } return true; } } diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index bfead0d..6765c4f 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -117,12 +117,12 @@ void MainWindow::setupUi() { void MainWindow::createActions() { m_newAction = new QAction(Icons::fileNew(), tr("&New Show"), this); m_newAction->setShortcut(QKeySequence::New); - m_newAction->setToolTip(tr("Create a new show (Ctrl+N)")); + m_newAction->setToolTip(tr("New show (Ctrl+N)")); connect(m_newAction, &QAction::triggered, this, &MainWindow::newShow); m_openAction = new QAction(Icons::fileOpen(), tr("&Open..."), this); m_openAction->setShortcut(QKeySequence::Open); - m_openAction->setToolTip(tr("Open an existing show (Ctrl+O)")); + m_openAction->setToolTip(tr("Open show (Ctrl+O)")); connect(m_openAction, &QAction::triggered, this, &MainWindow::openShow); m_importTmixAction = new QAction(tr("&Import Show File..."), this); @@ -131,7 +131,7 @@ void MainWindow::createActions() { m_saveAction = new QAction(Icons::fileSave(), tr("&Save"), this); m_saveAction->setShortcut(QKeySequence::Save); - m_saveAction->setToolTip(tr("Save the current show (Ctrl+S)")); + m_saveAction->setToolTip(tr("Save show (Ctrl+S)")); connect(m_saveAction, &QAction::triggered, this, &MainWindow::saveShow); m_saveAsAction = new QAction(Icons::fileSaveAs(), tr("Save &As..."), this); @@ -154,12 +154,12 @@ void MainWindow::createActions() { m_addCueAction = new QAction(Icons::listAdd(), tr("&Add Cue"), this); m_addCueAction->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_N)); - m_addCueAction->setToolTip(tr("Add a new cue to the list (Ctrl+Shift+N)")); + m_addCueAction->setToolTip(tr("Add cue (Ctrl+Shift+N)")); connect(m_addCueAction, &QAction::triggered, this, &MainWindow::addCue); m_deleteCueAction = new QAction(Icons::listRemove(), tr("&Delete Cue"), this); m_deleteCueAction->setShortcut(QKeySequence::Delete); - m_deleteCueAction->setToolTip(tr("Delete the selected cue (Delete)")); + m_deleteCueAction->setToolTip(tr("Delete cue (Del)")); connect(m_deleteCueAction, &QAction::triggered, this, &MainWindow::deleteCue); m_renumberAction = new QAction(tr("&Renumber Cues..."), this); @@ -240,28 +240,28 @@ void MainWindow::createActions() { m_goAction = new QAction(Icons::mediaPlay(), tr("&GO"), this); m_goAction->setShortcut(Qt::Key_Space); - m_goAction->setToolTip(tr("Execute the next cue (Space)")); + m_goAction->setToolTip(tr("Go (Space)")); connect(m_goAction, &QAction::triggered, this, &MainWindow::go); m_stopAction = new QAction(Icons::mediaStop(), tr("&Stop"), this); m_stopAction->setShortcut(Qt::Key_Escape); - m_stopAction->setToolTip(tr("Stop playback (Escape)")); + m_stopAction->setToolTip(tr("Stop (Esc)")); connect(m_stopAction, &QAction::triggered, this, &MainWindow::stopPlayback); m_previousCueAction = new QAction(Icons::mediaPrevious(), tr("&Previous Cue"), this); m_previousCueAction->setShortcut(Qt::Key_Up); - m_previousCueAction->setToolTip(tr("Select the previous cue (Up)")); + m_previousCueAction->setToolTip(tr("Previous cue (Up)")); connect(m_previousCueAction, &QAction::triggered, [this]() { m_app->playbackEngine()->previous(); }); m_nextCueAction = new QAction(Icons::mediaNext(), tr("&Next Cue"), this); m_nextCueAction->setShortcut(Qt::Key_Down); - m_nextCueAction->setToolTip(tr("Select the next cue (Down)")); + m_nextCueAction->setToolTip(tr("Next cue (Down)")); connect(m_nextCueAction, &QAction::triggered, [this]() { m_app->playbackEngine()->next(); }); m_panicAction = new QAction(Icons::warning(), tr("PANIC"), this); m_panicAction->setShortcut(Qt::SHIFT | Qt::Key_Escape); - m_panicAction->setToolTip(tr("Emergency stop - recall safe values (Shift+Escape)")); + m_panicAction->setToolTip(tr("Panic (Shift+Esc)")); connect(m_panicAction, &QAction::triggered, this, &MainWindow::panic); m_panicRestoreAction = new QAction(Icons::warning(), tr("Panic + Restore"), this); From 444782a84e810a0d9694b28cd3f192e68cb31fd2 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 19:18:12 -0400 Subject: [PATCH 63/92] revert: use default tooltip positioning --- src/main.cpp | 42 ------------------------------------------ 1 file changed, 42 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index acaf4cd..f1540ff 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,54 +6,13 @@ #include #include #include -#include -#include -#include #include #include #include -#include -#include #include namespace OpenMix { -// Show tooltips horizontally centered under the widget (and center multi-line -// text), rather than the platform's offset-to-the-right placement. -class CenteredToolTipFilter : public QObject { - public: - using QObject::QObject; - bool eventFilter(QObject* obj, QEvent* event) override { - if (event->type() == QEvent::ToolTip) { - auto* widget = qobject_cast(obj); - if (widget) { - // toolbar buttons carry the tooltip on their action, not the widget - QString tip = widget->toolTip(); - if (tip.isEmpty()) - if (auto* button = qobject_cast(widget)) - if (QAction* action = button->defaultAction()) - tip = action->toolTip(); - - if (!tip.isEmpty()) { - const QString html = "
" + - tip.toHtmlEscaped().replace("\n", "
") + "
"; - const QPoint cursor = static_cast(event)->globalPos(); - QToolTip::showText(QPoint(cursor.x(), cursor.y() + 16), html, widget); - // recenter on the cursor using the tooltip's real rendered width - for (QWidget* tipLabel : QApplication::topLevelWidgets()) { - if (tipLabel->isVisible() && tipLabel->inherits("QTipLabel")) { - tipLabel->move(cursor.x() - tipLabel->width() / 2, tipLabel->y()); - break; - } - } - return true; - } - } - } - return QObject::eventFilter(obj, event); - } -}; - // Show tooltips almost immediately and keep them up longer than the platform // default, so hovering an icon reveals its label without a long wait. class FastTooltipStyle : public QProxyStyle { @@ -83,7 +42,6 @@ int main(int argc, char* argv[]) { // Fusion base, but show tooltips faster than the platform default QApplication::setStyle(new OpenMix::FastTooltipStyle(QStyleFactory::create("Fusion"))); - qtApp.installEventFilter(new OpenMix::CenteredToolTipFilter(&qtApp)); // force dark theme on all platforms QPalette darkPalette; From 5f5a286c96a88232de3de2997d2b69351e9c9f3c Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 19:25:11 -0400 Subject: [PATCH 64/92] fix: crash deleting the playing cue (model remove ordering + stale playback index) --- src/core/CueList.cpp | 1 + src/core/CueList.h | 1 + src/core/PlaybackEngine.cpp | 33 ++++++++++++++-- src/core/PlaybackEngine.h | 1 + src/ui/CueTableModel.cpp | 8 +++- src/ui/CueTableModel.h | 1 + tests/CMakeLists.txt | 13 +++++++ tests/test_delete_playing_cue.cpp | 63 +++++++++++++++++++++++++++++++ 8 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 tests/test_delete_playing_cue.cpp diff --git a/src/core/CueList.cpp b/src/core/CueList.cpp index d6fcc66..157dacf 100644 --- a/src/core/CueList.cpp +++ b/src/core/CueList.cpp @@ -64,6 +64,7 @@ void CueList::updateCue(int index, const Cue& cue) { void CueList::removeCue(int index) { if (index >= 0 && index < m_cues.size()) { + emit cueAboutToBeRemoved(index); m_cues.remove(index); emit cueRemoved(index); } diff --git a/src/core/CueList.h b/src/core/CueList.h index c16d3b7..64c2d77 100644 --- a/src/core/CueList.h +++ b/src/core/CueList.h @@ -49,6 +49,7 @@ class CueList : public QObject { signals: void cueAdded(int index); + void cueAboutToBeRemoved(int index); // emitted before the cue is erased void cueRemoved(int index); void cueUpdated(int index); void cueMoved(int fromIndex, int toIndex); diff --git a/src/core/PlaybackEngine.cpp b/src/core/PlaybackEngine.cpp index f0aea54..4a3f404 100644 --- a/src/core/PlaybackEngine.cpp +++ b/src/core/PlaybackEngine.cpp @@ -20,10 +20,37 @@ PlaybackEngine::PlaybackEngine(QObject* parent) : QObject(parent) { } void PlaybackEngine::setCueList(CueList* cueList) { + if (m_cueList) + m_cueList->disconnect(this); m_cueList = cueList; stop(); - if (m_cueList && !m_cueList->isEmpty()) { - setStandbyIndex(0); + if (m_cueList) { + connect(m_cueList, &CueList::cueRemoved, this, &PlaybackEngine::onCueRemoved); + if (!m_cueList->isEmpty()) + setStandbyIndex(0); + } +} + +void PlaybackEngine::onCueRemoved(int index) { + // keep our indices valid when a cue is deleted underneath playback + if (m_currentIndex == index) { + // the playing cue itself was removed: abandon it and stop follow-on + m_autoFollowTimer.stop(); + m_autoFollowArmed = false; + m_currentIndex = -1; + emit currentCueChanged(m_currentIndex); + } else if (m_currentIndex > index) { + --m_currentIndex; + } + + if (m_standbyIndex == index) { + int fallback = -1; + if (m_cueList && !m_cueList->isEmpty()) + fallback = qMin(index, m_cueList->count() - 1); + m_standbyIndex = -2; // force setStandbyIndex to emit + setStandbyIndex(fallback); + } else if (m_standbyIndex > index) { + setStandbyIndex(m_standbyIndex - 1); } } @@ -79,7 +106,7 @@ void PlaybackEngine::go() { emit autoFollowArmed(false); } - if (!m_cueList || m_standbyIndex < 0) + if (!m_cueList || m_standbyIndex < 0 || m_standbyIndex >= m_cueList->count()) return; if (m_guard && m_guard->isGoLocked()) { diff --git a/src/core/PlaybackEngine.h b/src/core/PlaybackEngine.h index a40ec46..d13d3b0 100644 --- a/src/core/PlaybackEngine.h +++ b/src/core/PlaybackEngine.h @@ -130,6 +130,7 @@ class PlaybackEngine : public QObject { private slots: void onAutoFollowTimerTimeout(); + void onCueRemoved(int index); private: void setState(PlaybackState state); diff --git a/src/ui/CueTableModel.cpp b/src/ui/CueTableModel.cpp index 8b93ed5..7c093a1 100644 --- a/src/ui/CueTableModel.cpp +++ b/src/ui/CueTableModel.cpp @@ -15,6 +15,8 @@ CueTableModel::CueTableModel(CueList* cueList, QObject* parent) : QAbstractTableModel(parent), m_cueList(cueList) { if (m_cueList) { connect(m_cueList, &CueList::cueAdded, this, &CueTableModel::onCueAdded); + connect(m_cueList, &CueList::cueAboutToBeRemoved, this, + &CueTableModel::onCueAboutToBeRemoved); connect(m_cueList, &CueList::cueRemoved, this, &CueTableModel::onCueRemoved); connect(m_cueList, &CueList::cueUpdated, this, &CueTableModel::onCueUpdated); connect(m_cueList, &CueList::cueMoved, this, &CueTableModel::onCueMoved); @@ -387,8 +389,12 @@ void CueTableModel::onCueAdded(int index) { endInsertRows(); } -void CueTableModel::onCueRemoved(int index) { +void CueTableModel::onCueAboutToBeRemoved(int index) { + // must bracket the removal before the row is erased from the list beginRemoveRows(QModelIndex(), index, index); +} + +void CueTableModel::onCueRemoved(int index) { endRemoveRows(); // adjust highlight indices diff --git a/src/ui/CueTableModel.h b/src/ui/CueTableModel.h index b751fcd..980e783 100644 --- a/src/ui/CueTableModel.h +++ b/src/ui/CueTableModel.h @@ -67,6 +67,7 @@ class CueTableModel : public QAbstractTableModel { private slots: void onCueAdded(int index); + void onCueAboutToBeRemoved(int index); void onCueRemoved(int index); void onCueUpdated(int index); void onCueMoved(int from, int to); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 179ab2a..0f3b27b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -201,6 +201,19 @@ target_link_libraries(test_scs_client PRIVATE Qt6::Core Qt6::Test PkgConfig::LIB target_include_directories(test_scs_client PRIVATE ${CMAKE_SOURCE_DIR}/src) add_test(NAME ScsClientTest COMMAND test_scs_client) +add_executable(test_delete_playing_cue + test_delete_playing_cue.cpp + ${CMAKE_SOURCE_DIR}/src/ui/CueTableModel.cpp + ${CMAKE_SOURCE_DIR}/src/ui/CueFilterProxyModel.cpp + ${CMAKE_SOURCE_DIR}/src/ui/theme/Theme.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_delete_playing_cue PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Test) +target_include_directories(test_delete_playing_cue PRIVATE ${CMAKE_SOURCE_DIR}/src) +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 diff --git a/tests/test_delete_playing_cue.cpp b/tests/test_delete_playing_cue.cpp new file mode 100644 index 0000000..cd3a56e --- /dev/null +++ b/tests/test_delete_playing_cue.cpp @@ -0,0 +1,63 @@ +#include "core/Cue.h" +#include "core/CueList.h" +#include "ui/CueFilterProxyModel.h" +#include "ui/CueTableModel.h" +#include +#include +#include + +using namespace OpenMix; + +// Deleting the cue that is currently playing (highlighted + selected) used to +// crash because the model bracketed the row removal after the data was already +// gone. This exercises the model/proxy/view path to guard against regression. +class TestDeletePlayingCue : public QObject { + Q_OBJECT + + private slots: + void deleteCurrentSelectedRow() { + CueList list; + for (int i = 1; i <= 5; ++i) { + Cue c; + c.setNumber(i); + list.addCue(c); + } + + CueTableModel model(&list); + CueFilterProxyModel proxy; + proxy.setSourceModel(&model); + + QTableView view; + view.setModel(&proxy); + + // mark row 2 as the playing cue and select it + model.setCurrentCueIndex(2); + model.setStandbyCueIndex(3); + view.selectRow(2); + + // delete the playing cue + list.removeCue(2); + QCoreApplication::processEvents(); + + QCOMPARE(model.rowCount(), 4); + QCOMPARE(model.currentCueIndex(), -1); // playing cue is gone + QCOMPARE(model.standbyCueIndex(), 2); // shifted down by one + } + + void deleteRowBeforeCurrent() { + CueList list; + for (int i = 1; i <= 4; ++i) { + Cue c; + c.setNumber(i); + list.addCue(c); + } + CueTableModel model(&list); + model.setCurrentCueIndex(3); + list.removeCue(1); + QCOMPARE(model.rowCount(), 3); + QCOMPARE(model.currentCueIndex(), 2); // shifted down + } +}; + +QTEST_MAIN(TestDeletePlayingCue) +#include "test_delete_playing_cue.moc" From 4bf19a7857bafe6b43d18772e071f335d4eef1bd Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 19:38:11 -0400 Subject: [PATCH 65/92] fix: multiple crash-class bugs (move/insert model ordering, GoTo/macro recursion, DiGiCo length overflow, stale index guards) --- src/core/CueList.cpp | 6 +++- src/core/CueList.h | 2 ++ src/core/PlaybackEngine.cpp | 11 +++++++ src/core/PlaybackEngine.h | 5 ++++ src/core/SpareBackup.cpp | 6 +++- src/protocol/digico/DiGiCoProtocol.cpp | 19 ++++++++---- src/ui/CueItemDelegates.cpp | 13 +++++--- src/ui/CueListView.cpp | 7 +++-- src/ui/CueTableModel.cpp | 32 +++++++++++++------- src/ui/CueTableModel.h | 2 ++ tests/test_delete_playing_cue.cpp | 41 ++++++++++++++++++++++++++ 11 files changed, 121 insertions(+), 23 deletions(-) diff --git a/src/core/CueList.cpp b/src/core/CueList.cpp index 157dacf..596b3dd 100644 --- a/src/core/CueList.cpp +++ b/src/core/CueList.cpp @@ -42,8 +42,10 @@ Cue* CueList::findByNumber(double number) { } void CueList::addCue(const Cue& cue) { + const int index = m_cues.size(); + emit cueAboutToBeAdded(index); m_cues.append(cue); - emit cueAdded(m_cues.size() - 1); + emit cueAdded(index); } void CueList::insertCue(int index, const Cue& cue) { @@ -51,6 +53,7 @@ void CueList::insertCue(int index, const Cue& cue) { index = 0; if (index > m_cues.size()) index = m_cues.size(); + emit cueAboutToBeAdded(index); m_cues.insert(index, cue); emit cueAdded(index); } @@ -84,6 +87,7 @@ void CueList::moveCue(int fromIndex, int toIndex) { if (fromIndex == toIndex) return; + emit cueAboutToBeMoved(fromIndex, toIndex); Cue cue = m_cues.takeAt(fromIndex); m_cues.insert(toIndex, cue); emit cueMoved(fromIndex, toIndex); diff --git a/src/core/CueList.h b/src/core/CueList.h index 64c2d77..7501419 100644 --- a/src/core/CueList.h +++ b/src/core/CueList.h @@ -48,10 +48,12 @@ class CueList : public QObject { void fromJson(const QJsonArray& json); signals: + void cueAboutToBeAdded(int index); // emitted before the cue is inserted void cueAdded(int index); void cueAboutToBeRemoved(int index); // emitted before the cue is erased void cueRemoved(int index); void cueUpdated(int index); + void cueAboutToBeMoved(int fromIndex, int toIndex); // emitted before the move void cueMoved(int fromIndex, int toIndex); void listCleared(); void listLoaded(); diff --git a/src/core/PlaybackEngine.cpp b/src/core/PlaybackEngine.cpp index 4a3f404..8d03430 100644 --- a/src/core/PlaybackEngine.cpp +++ b/src/core/PlaybackEngine.cpp @@ -272,6 +272,17 @@ void PlaybackEngine::executeCueInternal(const Cue& cue) { if (!m_mixer) return; + // stop cyclic GoTo/macro chains before they overflow the stack + if (m_fireDepth >= kMaxFireDepth) { + qWarning("PlaybackEngine: cue fire recursion limit reached; aborting chain"); + return; + } + ++m_fireDepth; + struct DepthGuard { + int& depth; + ~DepthGuard() { --depth; } + } depthGuard{m_fireDepth}; + // resolve which DCAs to target QSet targetDCAs = cue.targetsAllDCAs() ? allDCAs() : cue.targetedDCAs(); diff --git a/src/core/PlaybackEngine.h b/src/core/PlaybackEngine.h index d13d3b0..1b02959 100644 --- a/src/core/PlaybackEngine.h +++ b/src/core/PlaybackEngine.h @@ -183,6 +183,11 @@ class PlaybackEngine : public QObject { int m_currentIndex = -1; int m_standbyIndex = -1; + // guards against runaway recursion from self/cyclic GoTo auto-execute or + // macro cycles (a crafted show file can create these) + int m_fireDepth = 0; + static constexpr int kMaxFireDepth = 50; + QTimer m_autoFollowTimer; bool m_autoFollowArmed = false; diff --git a/src/core/SpareBackup.cpp b/src/core/SpareBackup.cpp index 002cfcf..792b36a 100644 --- a/src/core/SpareBackup.cpp +++ b/src/core/SpareBackup.cpp @@ -84,7 +84,11 @@ QJsonObject SpareBackup::toJson() const { void SpareBackup::loadFromJson(const QJsonObject& json) { m_spareChannel = json.value("spareChannel").toInt(-1); m_allocatedChannel = json.value("allocatedChannel").toInt(-1); - m_state = static_cast(json.value("state").toInt(0)); + const int stateInt = json.value("state").toInt(0); + m_state = (stateInt >= static_cast(State::Inactive) && + stateInt <= static_cast(State::Active)) + ? static_cast(stateInt) + : State::Inactive; if (m_spareChannel < 0) { m_allocatedChannel = -1; m_state = State::Inactive; diff --git a/src/protocol/digico/DiGiCoProtocol.cpp b/src/protocol/digico/DiGiCoProtocol.cpp index 482dd2b..63e6ad7 100644 --- a/src/protocol/digico/DiGiCoProtocol.cpp +++ b/src/protocol/digico/DiGiCoProtocol.cpp @@ -247,18 +247,27 @@ void DiGiCoProtocol::refresh() { 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) { - quint32 length = qFromBigEndian( + const quint32 length = qFromBigEndian( reinterpret_cast(m_receiveBuffer.constData() + 4)); + const qint64 total = qint64(8) + length; // 64-bit: never wraps - if (static_cast(m_receiveBuffer.size()) < 8 + length) + 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(8 + length); + QByteArray frame = m_receiveBuffer.left(static_cast(total)); QByteArray tag = m_receiveBuffer.left(4); - QByteArray body = m_receiveBuffer.mid(8, length); - m_receiveBuffer.remove(0, 8 + length); + 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")) diff --git a/src/ui/CueItemDelegates.cpp b/src/ui/CueItemDelegates.cpp index 7ea55e4..b5e2611 100644 --- a/src/ui/CueItemDelegates.cpp +++ b/src/ui/CueItemDelegates.cpp @@ -84,10 +84,15 @@ void CueNumberDelegate::setModelData(QWidget* editor, QAbstractItemModel* model, if (m_cueList) { const auto existingIndex = m_cueList->indexOfNumber(newNumber); if (existingIndex && *existingIndex != index.row()) { - QMessageBox::warning( - qobject_cast(editor->parent()), QObject::tr("Cue Number Conflict"), - QObject::tr("Cue %1 already exists. Please choose a different number.") - .arg(newNumber, 0, 'f', 1)); + // defer the modal: showing it here would pump the event loop during + // Qt's editor-commit and can destroy the editor underneath us + const double dup = newNumber; + QTimer::singleShot(0, [dup]() { + QMessageBox::warning( + nullptr, QObject::tr("Cue Number Conflict"), + QObject::tr("Cue %1 already exists. Please choose a different number.") + .arg(dup, 0, 'f', 1)); + }); return; } } diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index 2c9db7d..8c61dca 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -180,9 +180,12 @@ int CueListView::selectedCueIndex() const { if (!current.isValid()) return -1; - // map from proxy to source + // map from proxy to source, and never hand back an out-of-range row QModelIndex sourceIndex = m_proxyModel->mapToSource(current); - return sourceIndex.row(); + const int row = sourceIndex.row(); + if (row < 0 || !m_model->cueList() || row >= m_model->cueList()->count()) + return -1; + return row; } void CueListView::setCurrentCueHighlight(int index) { diff --git a/src/ui/CueTableModel.cpp b/src/ui/CueTableModel.cpp index 7c093a1..ea1165f 100644 --- a/src/ui/CueTableModel.cpp +++ b/src/ui/CueTableModel.cpp @@ -14,11 +14,15 @@ const QString CueTableModel::s_mimeType = QStringLiteral("application/x-openmix- CueTableModel::CueTableModel(CueList* cueList, QObject* parent) : QAbstractTableModel(parent), m_cueList(cueList) { if (m_cueList) { + connect(m_cueList, &CueList::cueAboutToBeAdded, this, + &CueTableModel::onCueAboutToBeAdded); connect(m_cueList, &CueList::cueAdded, this, &CueTableModel::onCueAdded); connect(m_cueList, &CueList::cueAboutToBeRemoved, this, &CueTableModel::onCueAboutToBeRemoved); connect(m_cueList, &CueList::cueRemoved, this, &CueTableModel::onCueRemoved); connect(m_cueList, &CueList::cueUpdated, this, &CueTableModel::onCueUpdated); + connect(m_cueList, &CueList::cueAboutToBeMoved, this, + &CueTableModel::onCueAboutToBeMoved); connect(m_cueList, &CueList::cueMoved, this, &CueTableModel::onCueMoved); connect(m_cueList, &CueList::listCleared, this, &CueTableModel::onListCleared); connect(m_cueList, &CueList::listLoaded, this, &CueTableModel::onListLoaded); @@ -384,9 +388,18 @@ const Cue* CueTableModel::cueAt(int row) const { return &m_cueList->at(row); } -void CueTableModel::onCueAdded(int index) { +void CueTableModel::onCueAboutToBeAdded(int index) { beginInsertRows(QModelIndex(), index, index); +} + +void CueTableModel::onCueAdded(int index) { endInsertRows(); + + // shift highlights for rows that moved down by the insertion + if (m_currentIndex >= index) + ++m_currentIndex; + if (m_standbyIndex >= index) + ++m_standbyIndex; } void CueTableModel::onCueAboutToBeRemoved(int index) { @@ -415,16 +428,15 @@ void CueTableModel::onCueUpdated(int index) { emit dataChanged(this->index(index, 0), this->index(index, ColCount - 1)); } -void CueTableModel::onCueMoved(int from, int to) { - // use moveRows from Qt to update the view - // CueList already moved the item, this just notifies the view - // so selections/scroll position/other visual states stay in sync - // - // destinationChild is the row before the moved rows, so - // moving down → to + 1, moving up → to - int destRow = (from < to) ? to + 1 : to; - +void CueTableModel::onCueAboutToBeMoved(int from, int to) { + // beginMoveRows must bracket the move before the list is reordered. + // destinationChild is the row before the moved rows: moving down → to + 1, + // moving up → to + const int destRow = (from < to) ? to + 1 : to; beginMoveRows(QModelIndex(), from, from, QModelIndex(), destRow); +} + +void CueTableModel::onCueMoved(int from, int to) { endMoveRows(); // adjust highlight indices diff --git a/src/ui/CueTableModel.h b/src/ui/CueTableModel.h index 980e783..aa3cb37 100644 --- a/src/ui/CueTableModel.h +++ b/src/ui/CueTableModel.h @@ -66,10 +66,12 @@ class CueTableModel : public QAbstractTableModel { const QVariant& newValue); private slots: + void onCueAboutToBeAdded(int index); void onCueAdded(int index); void onCueAboutToBeRemoved(int index); void onCueRemoved(int index); void onCueUpdated(int index); + void onCueAboutToBeMoved(int from, int to); void onCueMoved(int from, int to); void onListCleared(); void onListLoaded(); diff --git a/tests/test_delete_playing_cue.cpp b/tests/test_delete_playing_cue.cpp index cd3a56e..fc6e700 100644 --- a/tests/test_delete_playing_cue.cpp +++ b/tests/test_delete_playing_cue.cpp @@ -57,6 +57,47 @@ class TestDeletePlayingCue : public QObject { QCOMPARE(model.rowCount(), 3); QCOMPARE(model.currentCueIndex(), 2); // shifted down } + + void moveCurrentSelectedRow() { + CueList list; + for (int i = 1; i <= 5; ++i) { + Cue c; + c.setNumber(i); + list.addCue(c); + } + CueTableModel model(&list); + CueFilterProxyModel proxy; + proxy.setSourceModel(&model); + QTableView view; + view.setModel(&proxy); + + model.setCurrentCueIndex(1); + view.selectRow(1); + + list.moveCue(1, 3); // drag the current row down + QCoreApplication::processEvents(); + + QCOMPARE(model.rowCount(), 5); + QCOMPARE(model.currentCueIndex(), 3); // highlight follows the move + } + + void insertShiftsHighlight() { + CueList list; + for (int i = 1; i <= 3; ++i) { + Cue c; + c.setNumber(i); + list.addCue(c); + } + CueTableModel model(&list); + model.setCurrentCueIndex(1); + + Cue extra; + extra.setNumber(99); + list.insertCue(0, extra); // insert before the current row + + QCOMPARE(model.rowCount(), 4); + QCOMPARE(model.currentCueIndex(), 2); // shifted down by the insert + } }; QTEST_MAIN(TestDeletePlayingCue) From ea3c3099ea884f6fe7ce1776327f8b809278b46a Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 19:49:14 -0400 Subject: [PATCH 66/92] fix: nested-macro state clobber (local state) and FadeEngine advance re-entrancy --- src/core/FadeEngine.cpp | 40 ++++++++++++------ src/core/PlaybackEngine.cpp | 81 +++++-------------------------------- src/core/PlaybackEngine.h | 4 -- tests/test_fade_engine.cpp | 30 ++++++++++++++ 4 files changed, 69 insertions(+), 86 deletions(-) diff --git a/src/core/FadeEngine.cpp b/src/core/FadeEngine.cpp index 1f0e610..e9e31f1 100644 --- a/src/core/FadeEngine.cpp +++ b/src/core/FadeEngine.cpp @@ -76,23 +76,39 @@ void FadeEngine::advance(double dtMs) { if (m_fades.isEmpty()) return; - QStringList completedKeys; - - for (int i = 0; i < m_fades.size();) { - Fade& f = m_fades[i]; + // Compute this tick's values without invoking any callback yet, and copy + // out each apply function. This makes advance() re-entrancy-safe: an apply + // callback (or a fadeCompleted slot) may call start()/cancel() and realloc + // m_fades without invalidating a reference we're holding mid-iteration. + struct Step { + QString key; + double value; + bool done; + ApplyFn apply; + }; + QVector steps; + steps.reserve(m_fades.size()); + for (Fade& f : m_fades) { f.elapsedMs += dtMs; - double t = std::clamp(f.elapsedMs / f.durationMs, 0.0, 1.0); - double value = f.from + (f.to - f.from) * ease(f.curve, t); - f.apply(value); + const double t = std::clamp(f.elapsedMs / f.durationMs, 0.0, 1.0); + const double value = f.from + (f.to - f.from) * ease(f.curve, t); + steps.append({f.key, value, t >= 1.0, f.apply}); + } - if (t >= 1.0) { - completedKeys.append(f.key); - m_fades.removeAt(i); - } else { - ++i; + // Remove completed fades before running callbacks so a callback sees a + // consistent container. + QStringList completedKeys; + for (const Step& s : steps) { + if (s.done) { + completedKeys.append(s.key); + cancel(s.key); } } + // Now invoke callbacks from local copies; safe even if they mutate m_fades. + for (const Step& s : steps) + s.apply(s.value); + if (m_fades.isEmpty()) m_timer.stop(); diff --git a/src/core/PlaybackEngine.cpp b/src/core/PlaybackEngine.cpp index 8d03430..b67b543 100644 --- a/src/core/PlaybackEngine.cpp +++ b/src/core/PlaybackEngine.cpp @@ -150,9 +150,6 @@ void PlaybackEngine::toggleChannelMute(int channel) { void PlaybackEngine::stop() { m_autoFollowTimer.stop(); m_autoFollowArmed = false; - m_currentMacroId.clear(); - m_macroChildIndex = 0; - m_macroPendingChildren.clear(); setState(PlaybackState::Stopped); setCurrentIndex(-1); @@ -691,72 +688,20 @@ void PlaybackEngine::executeMacroCue(const Cue& cue) { return; } - if (cue.macroExecutionMode() == MacroExecutionMode::Parallel) { - for (const QString& childId : childIds) { - const Cue* childCue = m_cueList->findById(childId); - if (childCue) { - executeCueInternal(*childCue); - emit macroChildExecuted(cue.id(), childId); - } - } - emit cueCompleted(m_currentIndex); - handleAutoFollow(cue); - } else { - m_currentMacroId = cue.id(); - m_macroChildIndex = 0; - m_macroPendingChildren = childIds; - - const Cue* firstChild = m_cueList->findById(childIds.first()); - if (firstChild) { - m_macroPendingChildren.removeFirst(); - executeCueInternal(*firstChild); - emit macroChildExecuted(cue.id(), childIds.first()); - m_macroChildIndex = 1; - - if (m_macroPendingChildren.isEmpty()) { - const Cue* macroCue = m_cueList->findById(m_currentMacroId); - - m_currentMacroId.clear(); - m_macroPendingChildren.clear(); - m_macroChildIndex = 0; - - emit cueCompleted(m_currentIndex); - if (macroCue) { - handleAutoFollow(*macroCue); - } - } else { - executeNextMacroChild(); - } - } - } -} - -void PlaybackEngine::executeNextMacroChild() { - if (m_currentMacroId.isEmpty() || !m_cueList) { - m_currentMacroId.clear(); - m_macroPendingChildren.clear(); - m_macroChildIndex = 0; - return; - } - - while (!m_macroPendingChildren.isEmpty()) { - QString childId = m_macroPendingChildren.takeFirst(); + // Parallel and sequential modes both fire the children synchronously in + // order. Use only local state so a child that is itself a macro cannot + // clobber this macro's iteration (nested-macro safety). executeCueInternal + // is recursion-depth-guarded, so a macro cycle cannot overflow the stack. + const QString macroId = cue.id(); + for (const QString& childId : childIds) { const Cue* childCue = m_cueList->findById(childId); - if (!childCue) - continue; - executeCueInternal(*childCue); - emit macroChildExecuted(m_currentMacroId, childId); - m_macroChildIndex++; + if (childCue) { + executeCueInternal(*childCue); + emit macroChildExecuted(macroId, childId); + } } - - const Cue* macroCue = m_cueList->findById(m_currentMacroId); - m_currentMacroId.clear(); - m_macroPendingChildren.clear(); - m_macroChildIndex = 0; - emit cueCompleted(m_currentIndex); - if (macroCue) - handleAutoFollow(*macroCue); + handleAutoFollow(cue); } void PlaybackEngine::executeGoToCue(const Cue& cue) { @@ -803,10 +748,6 @@ void PlaybackEngine::executeStopCue(const Cue& cue) { m_autoFollowTimer.stop(); m_autoFollowArmed = false; - m_currentMacroId.clear(); - m_macroPendingChildren.clear(); - m_macroChildIndex = 0; - switch (cue.stopBehavior()) { case StopBehavior::StopOnly: setState(PlaybackState::Stopped); diff --git a/src/core/PlaybackEngine.h b/src/core/PlaybackEngine.h index 1b02959..8710877 100644 --- a/src/core/PlaybackEngine.h +++ b/src/core/PlaybackEngine.h @@ -143,7 +143,6 @@ class PlaybackEngine : public QObject { void executeMacroCue(const Cue& cue); void executeGoToCue(const Cue& cue); void executeStopCue(const Cue& cue); - void executeNextMacroChild(); void handleAutoFollow(const Cue& cue); // DCA filtering helpers @@ -191,9 +190,6 @@ class PlaybackEngine : public QObject { QTimer m_autoFollowTimer; bool m_autoFollowArmed = false; - QString m_currentMacroId; - int m_macroChildIndex = 0; - QStringList m_macroPendingChildren; CueValidator* m_validator = nullptr; PlaybackGuard* m_guard = nullptr; diff --git a/tests/test_fade_engine.cpp b/tests/test_fade_engine.cpp index 7a1eef0..4f81364 100644 --- a/tests/test_fade_engine.cpp +++ b/tests/test_fade_engine.cpp @@ -73,6 +73,36 @@ class TestFadeEngine : public QObject { QCOMPARE(stringToFadeCurve(fadeCurveToString(c)), c); } } + + // An apply callback that starts more fades mid-advance used to invalidate + // the Fade reference held by the loop (realloc → use-after-free). + void reentrantStartDuringAdvanceIsSafe() { + FadeEngine fe; + int aCalls = 0; + int bApplied = 0; + fe.start("a", 0.0, 1.0, 10.0, FadeCurve::Linear, [&](double) { + // start() applies 'from' immediately (call #1); only spawn on the + // second call, which happens while advance() iterates m_fades + if (++aCalls == 2) { + for (int i = 0; i < 8; ++i) + fe.start(QString("b%1").arg(i), 0.0, 1.0, 1000.0, FadeCurve::Linear, + [&](double) { ++bApplied; }); + } + }); + + fe.advance(20.0); // completes "a"; its apply spawns 8 long-lived fades + QCOMPARE(fe.activeCount(), 8); // spawned mid-advance, no crash, still active + QVERIFY(bApplied >= 8); // each spawned fade applied its 'from' + } + + // A callback that cancels another fade during advance must not corrupt it. + void reentrantCancelDuringAdvanceIsSafe() { + FadeEngine fe; + fe.start("keep", 0.0, 1.0, 100.0, FadeCurve::Linear, [](double) {}); + fe.start("victim", 0.0, 1.0, 100.0, FadeCurve::Linear, [&](double) { fe.cancel("keep"); }); + fe.advance(10.0); // victim's apply cancels "keep" mid-iteration + QVERIFY(true); // survived without crashing + } }; QTEST_MAIN(TestFadeEngine) From 00bf545a5d27181a41ab64e053a42409650e2c4d Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 20:00:04 -0400 Subject: [PATCH 67/92] feat: empty-state hint for the cue list --- src/ui/CueListView.cpp | 40 +++++++++++++++++++++++++++++++++++++++- src/ui/CueListView.h | 4 ++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index 8c61dca..a0f6881 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -14,7 +14,10 @@ #include #include +#include "theme/Theme.h" + #include +#include #include #include #include @@ -121,6 +124,37 @@ void CueListView::setupUi() { layout->addWidget(m_filterBar); layout->addWidget(m_tableView); + + // centered hint shown over the empty table so a fresh show isn't a blank void + m_emptyHint = new QLabel(m_tableView->viewport()); + m_emptyHint->setAlignment(Qt::AlignCenter); + m_emptyHint->setWordWrap(true); + m_emptyHint->setAttribute(Qt::WA_TransparentForMouseEvents); + m_emptyHint->setStyleSheet( + QString("color: %1; font-size: 13px;").arg(Theme::Colors::TextTertiary)); + m_emptyHint->hide(); + + auto refreshHint = [this]() { updateEmptyHint(); }; + connect(m_proxyModel, &QAbstractItemModel::rowsInserted, this, refreshHint); + connect(m_proxyModel, &QAbstractItemModel::rowsRemoved, this, refreshHint); + connect(m_proxyModel, &QAbstractItemModel::modelReset, this, refreshHint); + connect(m_proxyModel, &QAbstractItemModel::layoutChanged, this, refreshHint); + updateEmptyHint(); +} + +void CueListView::updateEmptyHint() { + if (!m_emptyHint) + return; + const bool empty = m_proxyModel->rowCount() == 0; + if (empty) { + const bool noCuesAtAll = !m_model->cueList() || m_model->cueList()->count() == 0; + m_emptyHint->setText(noCuesAtAll + ? tr("No cues yet.\nPress the + button (Ctrl+Shift+N) to add one.") + : tr("No cues match the current filter.")); + m_emptyHint->resize(m_tableView->viewport()->size()); + m_emptyHint->move(0, 0); + } + m_emptyHint->setVisible(empty); } void CueListView::setupDelegates() { @@ -554,7 +588,7 @@ void CueListView::onCueReordered(int fromIndex, int toIndex) { } void CueListView::onFiltersChanged() { - // could emit a signal or update status + updateEmptyHint(); } void CueListView::onTabNavigationRequested(const QModelIndex& fromIndex, bool forward) { @@ -581,6 +615,10 @@ void CueListView::onTabNavigationRequested(const QModelIndex& fromIndex, bool fo } bool CueListView::eventFilter(QObject* watched, QEvent* event) { + // keep the empty-state hint sized to the viewport + if (watched == m_tableView->viewport() && event->type() == QEvent::Resize) + updateEmptyHint(); + if (watched == m_tableView && event->type() == QEvent::FocusOut) { QFocusEvent* focusEvent = static_cast(event); if (focusEvent->reason() != Qt::PopupFocusReason && diff --git a/src/ui/CueListView.h b/src/ui/CueListView.h index d706bbf..9062d58 100644 --- a/src/ui/CueListView.h +++ b/src/ui/CueListView.h @@ -7,6 +7,8 @@ #include #include +class QLabel; + namespace OpenMix { class Application; @@ -76,6 +78,7 @@ class CueListView : public QWidget { void setupDelegates(); void saveColumnWidths(); void restoreColumnWidths(); + void updateEmptyHint(); void createActions(); void editNextCell(bool forward); QModelIndex nextEditableIndex(const QModelIndex& current, bool forward) const; @@ -86,6 +89,7 @@ class CueListView : public QWidget { Application* m_app; QTableView* m_tableView; + QLabel* m_emptyHint = nullptr; // centered hint shown when the table is empty CueTableModel* m_model; CueFilterProxyModel* m_proxyModel; CueFilterBar* m_filterBar; From 12fe00235f21cc4aabe20aa6d6ac914d9ec808ce Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 20:04:30 -0400 Subject: [PATCH 68/92] fix: macro cue type data-loss, empty-state hint, tooltip/mnemonic/unit papercuts --- src/ui/ActorSetupPanel.cpp | 4 ++++ src/ui/CueEditor.cpp | 44 ++++++-------------------------------- src/ui/MainWindow.cpp | 18 ++++++++-------- 3 files changed, 20 insertions(+), 46 deletions(-) diff --git a/src/ui/ActorSetupPanel.cpp b/src/ui/ActorSetupPanel.cpp index 6c8467d..9418709 100644 --- a/src/ui/ActorSetupPanel.cpp +++ b/src/ui/ActorSetupPanel.cpp @@ -264,6 +264,8 @@ class VoiceEditorWidget : public QWidget { auto* freq = new QDoubleSpinBox(m_eqTable); freq->setRange(20.0, 20000.0); freq->setDecimals(0); + freq->setSuffix(tr(" Hz")); + freq->setAlignment(Qt::AlignRight | Qt::AlignVCenter); freq->setValue(b.freq); connect(freq, QOverload::of(&QDoubleSpinBox::valueChanged), this, [this](double) { notify(); }); @@ -272,6 +274,8 @@ class VoiceEditorWidget : public QWidget { auto* gain = new QDoubleSpinBox(m_eqTable); gain->setRange(-18.0, 18.0); gain->setDecimals(1); + gain->setSuffix(tr(" dB")); + gain->setAlignment(Qt::AlignRight | Qt::AlignVCenter); gain->setValue(b.gain); connect(gain, QOverload::of(&QDoubleSpinBox::valueChanged), this, [this](double) { notify(); }); diff --git a/src/ui/CueEditor.cpp b/src/ui/CueEditor.cpp index 03f5c7d..14024bc 100644 --- a/src/ui/CueEditor.cpp +++ b/src/ui/CueEditor.cpp @@ -84,6 +84,7 @@ void CueEditor::setupUi() { m_typeCombo->addItem(tr("Stop"), static_cast(CueType::Stop)); m_typeCombo->addItem(tr("Go To"), static_cast(CueType::GoTo)); m_typeCombo->addItem(tr("Wait"), static_cast(CueType::Wait)); + m_typeCombo->addItem(tr("Macro"), static_cast(CueType::Macro)); basicLayout->addRow(tr("Type:"), m_typeCombo); QWidget* colorRow = new QWidget(this); @@ -167,13 +168,13 @@ void CueEditor::setupUi() { DCAOverrideWidgets widgets; - widgets.enableMute = new QCheckBox(tr("Set Mute:"), dcaBox); + widgets.enableMute = new QCheckBox(tr("Set Mute"), dcaBox); widgets.muteValue = new QCheckBox(tr("Muted"), dcaBox); widgets.muteValue->setEnabled(false); dcaLayout->addWidget(widgets.enableMute, 0, 0); dcaLayout->addWidget(widgets.muteValue, 0, 1); - widgets.enableLabel = new QCheckBox(tr("Set Label:"), dcaBox); + widgets.enableLabel = new QCheckBox(tr("Set Label"), dcaBox); widgets.labelValue = new QLineEdit(dcaBox); widgets.labelValue->setPlaceholderText(tr("DCA label")); widgets.labelValue->setEnabled(false); @@ -442,26 +443,9 @@ void CueEditor::updateFromCue() { m_numberSpin->setValue(cue->number()); m_nameEdit->setText(cue->name()); - // map cue type to combo index - int typeIndex = 0; - switch (cue->type()) { - case CueType::Snapshot: - typeIndex = 0; - break; - case CueType::Stop: - typeIndex = 1; - break; - case CueType::GoTo: - typeIndex = 2; - break; - case CueType::Wait: - typeIndex = 3; - break; - case CueType::Macro: - typeIndex = 0; - break; // macro not in combo - } - m_typeCombo->setCurrentIndex(typeIndex); + // map cue type to combo index by its stored data (robust to ordering) + const int typeIndex = m_typeCombo->findData(static_cast(cue->type())); + m_typeCombo->setCurrentIndex(typeIndex < 0 ? 0 : typeIndex); m_autoFollowCheck->setChecked(cue->autoFollow()); m_autoFollowDelaySpin->setValue(cue->autoFollowDelay()); @@ -639,21 +623,7 @@ void CueEditor::onTypeChanged(int index) { return; Cue* cue = currentCue(); if (cue) { - CueType type = CueType::Snapshot; - switch (index) { - case 0: - type = CueType::Snapshot; - break; - case 1: - type = CueType::Stop; - break; - case 2: - type = CueType::GoTo; - break; - case 3: - type = CueType::Wait; - break; - } + const CueType type = static_cast(m_typeCombo->itemData(index).toInt()); cue->setType(type); m_app->show()->cueList()->updateCue(m_currentIndex, *cue); emit cueModified(); diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 6765c4f..aa3e76b 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -162,7 +162,7 @@ void MainWindow::createActions() { m_deleteCueAction->setToolTip(tr("Delete cue (Del)")); connect(m_deleteCueAction, &QAction::triggered, this, &MainWindow::deleteCue); - m_renumberAction = new QAction(tr("&Renumber Cues..."), this); + m_renumberAction = new QAction(tr("&Renumber Cues"), this); m_renumberAction->setToolTip(tr("Renumber all cues sequentially")); connect(m_renumberAction, &QAction::triggered, this, &MainWindow::renumberCues); @@ -259,12 +259,12 @@ void MainWindow::createActions() { m_nextCueAction->setToolTip(tr("Next cue (Down)")); connect(m_nextCueAction, &QAction::triggered, [this]() { m_app->playbackEngine()->next(); }); - m_panicAction = new QAction(Icons::warning(), tr("PANIC"), this); + m_panicAction = new QAction(Icons::warning(), tr("&PANIC"), this); m_panicAction->setShortcut(Qt::SHIFT | Qt::Key_Escape); m_panicAction->setToolTip(tr("Panic (Shift+Esc)")); connect(m_panicAction, &QAction::triggered, this, &MainWindow::panic); - m_panicRestoreAction = new QAction(Icons::warning(), tr("Panic + Restore"), this); + m_panicRestoreAction = new QAction(Icons::warning(), tr("Pa&nic + Restore"), this); m_panicRestoreAction->setShortcut(Qt::CTRL | Qt::SHIFT | Qt::Key_Escape); m_panicRestoreAction->setToolTip( tr("Panic then restore to previous state (Ctrl+Shift+Escape)")); @@ -342,7 +342,7 @@ void MainWindow::createActions() { m_cueZeroAction->setToolTip(tr("Edit the base/reset state recalled before the first cue")); connect(m_cueZeroAction, &QAction::triggered, this, &MainWindow::showCueZeroDialog); - m_editHistoryAction = new QAction(tr("Edit &History"), this); + m_editHistoryAction = new QAction(tr("Edit &History..."), this); m_editHistoryAction->setToolTip(tr("Browse and step through the edit history")); connect(m_editHistoryAction, &QAction::triggered, this, &MainWindow::showEditHistoryDialog); @@ -350,7 +350,7 @@ void MainWindow::createActions() { m_exportCsvAction->setToolTip(tr("Export the cue list to a CSV file")); connect(m_exportCsvAction, &QAction::triggered, this, &MainWindow::exportCuesToCsv); - m_channelUtilizationAction = new QAction(tr("Channel &Utilization"), this); + m_channelUtilizationAction = new QAction(tr("Channel &Utilization..."), this); m_channelUtilizationAction->setToolTip(tr("Show which cues use each input channel")); connect(m_channelUtilizationAction, &QAction::triggered, this, &MainWindow::showChannelUtilizationDialog); @@ -365,20 +365,20 @@ void MainWindow::createActions() { connect(m_showLogViewerAction, &QAction::triggered, this, &MainWindow::showLogViewerDialog); // settings actions - m_keyboardShortcutsAction = new QAction(tr("Keyboard Shortcuts..."), this); + m_keyboardShortcutsAction = new QAction(tr("&Keyboard Shortcuts..."), this); m_keyboardShortcutsAction->setToolTip(tr("Configure keyboard shortcuts")); connect(m_keyboardShortcutsAction, &QAction::triggered, this, &MainWindow::showKeyboardShortcutsDialog); - m_midiControllerAction = new QAction(tr("MIDI Controller..."), this); + m_midiControllerAction = new QAction(tr("&MIDI Controller..."), this); m_midiControllerAction->setToolTip(tr("Configure MIDI controller mappings")); connect(m_midiControllerAction, &QAction::triggered, this, &MainWindow::showMidiConfigDialog); - m_remoteControlAction = new QAction(Icons::remoteControl(), tr("Remote Control..."), this); + m_remoteControlAction = new QAction(Icons::remoteControl(), tr("&Remote Control..."), this); m_remoteControlAction->setToolTip(tr("Configure MSC, inbound OSC, and QLab remote control")); connect(m_remoteControlAction, &QAction::triggered, this, &MainWindow::showRemoteControlDialog); - m_appSettingsAction = new QAction(tr("Settings..."), this); + m_appSettingsAction = new QAction(tr("&Settings..."), this); m_appSettingsAction->setToolTip( tr("Console behavior, scribble highlight, channel monitor, and QLab settings")); connect(m_appSettingsAction, &QAction::triggered, this, &MainWindow::showSettingsDialog); From bf67138ffd1b6c1d2188ff09bd0b804ed30bee97 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 20:25:48 -0400 Subject: [PATCH 69/92] feat: TheatreMix-style layout - cue grid over channel strip, editor as pop-out --- src/ui/MainWindow.cpp | 71 ++++++++++++++++++++++++++++--------------- src/ui/MainWindow.h | 4 ++- 2 files changed, 49 insertions(+), 26 deletions(-) diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index aa3e76b..d75ca07 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -97,19 +97,20 @@ MainWindow::MainWindow(Application* app, QWidget* parent) : QMainWindow(parent), MainWindow::~MainWindow() { saveSettings(); } void MainWindow::setupUi() { - m_mainSplitter = new QSplitter(Qt::Horizontal, this); + // cue grid fills the workspace with a channel-status strip beneath it; the + // cue editor is a pop-out opened on demand + m_mainSplitter = new QSplitter(Qt::Vertical, this); m_cueListView = new CueListView(m_app, this); - m_cueEditor = new CueEditor(m_app, this); + m_cueEditor = new CueEditor(m_app, this); // parented to a pop-out below + m_mixerFeedbackPanel = new MixerFeedbackPanel(m_app, this); + m_mixerFeedbackPanel->setMaximumHeight(240); m_mainSplitter->addWidget(m_cueListView); - m_mainSplitter->addWidget(m_cueEditor); - m_mainSplitter->setStretchFactor(0, 2); + m_mainSplitter->addWidget(m_mixerFeedbackPanel); + m_mainSplitter->setStretchFactor(0, 5); m_mainSplitter->setStretchFactor(1, 1); - m_mainSplitter->setChildrenCollapsible(false); - m_cueListView->setMinimumWidth(300); - m_cueEditor->setMinimumWidth(250); setCentralWidget(m_mainSplitter); } @@ -288,14 +289,22 @@ void MainWindow::createActions() { m_showDCAMappingAction->setToolTip(tr("Show/hide DCA mapping panel (F5)")); connect(m_showDCAMappingAction, &QAction::triggered, this, &MainWindow::toggleDCAMappingPanel); - m_showMixerFeedbackAction = new QAction(Icons::audioVolume(), tr("&Mixer Feedback"), this); + m_showMixerFeedbackAction = new QAction(Icons::audioVolume(), tr("Channel &Strip"), this); m_showMixerFeedbackAction->setCheckable(true); - m_showMixerFeedbackAction->setChecked(false); + m_showMixerFeedbackAction->setChecked(true); // strip is shown by default m_showMixerFeedbackAction->setShortcut(Qt::Key_F6); - m_showMixerFeedbackAction->setToolTip(tr("Show/hide mixer feedback panel (F6)")); + m_showMixerFeedbackAction->setToolTip(tr("Show/hide the channel-status strip (F6)")); connect(m_showMixerFeedbackAction, &QAction::triggered, this, &MainWindow::toggleMixerFeedbackPanel); + m_showCueEditorAction = new QAction(Icons::sliders(), tr("Cue &Editor"), this); + m_showCueEditorAction->setCheckable(true); + m_showCueEditorAction->setChecked(false); + m_showCueEditorAction->setShortcut(Qt::Key_F4); + m_showCueEditorAction->setToolTip(tr("Show/hide the cue editor (F4)")); + connect(m_showCueEditorAction, &QAction::triggered, this, + &MainWindow::toggleCueEditorPanel); + m_showConnectionAction = new QAction(Icons::network(), tr("&Connection Panel"), this); m_showConnectionAction->setCheckable(true); m_showConnectionAction->setChecked(false); @@ -511,6 +520,7 @@ void MainWindow::createMenus() { m_viewMenu = menuBar()->addMenu(tr("&View")); m_viewMenu->addAction(m_showDCAMappingAction); + m_viewMenu->addAction(m_showCueEditorAction); m_viewMenu->addAction(m_showMixerFeedbackAction); m_viewMenu->addAction(m_showConnectionAction); m_viewMenu->addAction(m_showActorSetupAction); @@ -692,15 +702,13 @@ void MainWindow::createPopOutWindows() { m_bubbleBar->setButtonActive("connection", visible); }); - m_mixerFeedbackPanel = new MixerFeedbackPanel(m_app, nullptr); - m_mixerFeedbackPopOut = new PopOutWindow("mixerFeedback", tr("Mixer Feedback"), this); - m_mixerFeedbackPopOut->setContentWidget(m_mixerFeedbackPanel); - m_mixerFeedbackPopOut->setMinimumContentSize(400, 350); + // the cue editor lives in a pop-out (the central area is the cue grid + strip) + m_cueEditorPopOut = new PopOutWindow("cueEditor", tr("Cue Editor"), this); + m_cueEditorPopOut->setContentWidget(m_cueEditor); + m_cueEditorPopOut->setMinimumContentSize(340, 620); - connect(m_mixerFeedbackPopOut, &PopOutWindow::visibilityChanged, [this](bool visible) { - m_showMixerFeedbackAction->setChecked(visible); - m_bubbleBar->setButtonActive("mixer", visible); - }); + connect(m_cueEditorPopOut, &PopOutWindow::visibilityChanged, + [this](bool visible) { m_showCueEditorAction->setChecked(visible); }); m_dcaMappingPanel = new DCAMappingPanel(m_app, nullptr); m_dcaMappingPopOut = new PopOutWindow("dcaMapping", tr("DCA Mapping"), this); @@ -851,8 +859,12 @@ void MainWindow::connectSignals() { } }); - connect(m_cueListView, &CueListView::cueDoubleClicked, - [this](int index) { m_app->playbackEngine()->executeCue(index); }); + // double-click opens the cue editor for that cue (GO/Space fires cues) + connect(m_cueListView, &CueListView::cueDoubleClicked, [this](int index) { + m_cueEditor->setCue(index); + m_cueEditorPopOut->showAndRestore(); + m_showCueEditorAction->setChecked(true); + }); connect(m_cueEditor, &CueEditor::cueModified, [this]() { m_cueListView->refreshCurrentCue(); }); @@ -873,7 +885,7 @@ void MainWindow::loadSettings() { settings.beginGroup("MainWindow"); // restore splitter state - QByteArray mainSplitterState = settings.value("mainSplitter").toByteArray(); + QByteArray mainSplitterState = settings.value("mainSplitterV2").toByteArray(); if (!mainSplitterState.isEmpty()) { m_mainSplitter->restoreState(mainSplitterState); } @@ -888,7 +900,7 @@ void MainWindow::saveSettings() { QSettings settings("OpenMix", "OpenMix"); settings.beginGroup("MainWindow"); - settings.setValue("mainSplitter", m_mainSplitter->saveState()); + settings.setValue("mainSplitterV2", m_mainSplitter->saveState()); settings.endGroup(); @@ -1152,10 +1164,19 @@ void MainWindow::toggleConnectionPanel() { } void MainWindow::toggleMixerFeedbackPanel() { - if (m_mixerFeedbackPopOut->isVisible()) { - m_mixerFeedbackPopOut->hide(); + // the mixer feedback is the bottom channel-status strip; toggle its visibility + const bool show = !m_mixerFeedbackPanel->isVisible(); + m_mixerFeedbackPanel->setVisible(show); + m_showMixerFeedbackAction->setChecked(show); + m_bubbleBar->setButtonActive("mixer", show); +} + +void MainWindow::toggleCueEditorPanel() { + if (m_cueEditorPopOut->isVisible()) { + m_cueEditorPopOut->hide(); } else { - m_mixerFeedbackPopOut->showAndRestore(); + m_cueEditorPopOut->showAndRestore(); + m_cueEditor->setCue(m_cueListView->selectedCueIndex()); } } diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 8b3d755..b77e309 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -69,6 +69,7 @@ class MainWindow : public QMainWindow { // view actions - pop-out windows void toggleConnectionPanel(); void toggleMixerFeedbackPanel(); + void toggleCueEditorPanel(); void toggleDCAMappingPanel(); void toggleActorSetupPanel(); void toggleEnsemblePanel(); @@ -145,7 +146,7 @@ class MainWindow : public QMainWindow { // pop-out windows PopOutWindow* m_connectionPopOut; - PopOutWindow* m_mixerFeedbackPopOut; + PopOutWindow* m_cueEditorPopOut; PopOutWindow* m_dcaMappingPopOut; PopOutWindow* m_actorSetupPopOut; PopOutWindow* m_ensemblePopOut; @@ -214,6 +215,7 @@ class MainWindow : public QMainWindow { // view actions (for menu checkable items) QAction* m_showConnectionAction; QAction* m_showMixerFeedbackAction; + QAction* m_showCueEditorAction; QAction* m_showDCAMappingAction; QAction* m_showActorSetupAction; QAction* m_showEnsembleAction; From 8c19b06b23f5527da4ed4d8bbd1c3622176a12da Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 20:27:43 -0400 Subject: [PATCH 70/92] feat: match TheatreMix menus (Cue List menu), column names (Cue/Text), Back Cue term --- src/ui/CueTableModel.cpp | 4 ++-- src/ui/MainWindow.cpp | 26 +++++++++++++++----------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/ui/CueTableModel.cpp b/src/ui/CueTableModel.cpp index ea1165f..1286cc8 100644 --- a/src/ui/CueTableModel.cpp +++ b/src/ui/CueTableModel.cpp @@ -168,9 +168,9 @@ QVariant CueTableModel::headerData(int section, Qt::Orientation orientation, int switch (section) { case ColNumber: - return tr("Q#"); + return tr("Cue"); case ColName: - return tr("Name"); + return tr("Text"); case ColType: return tr("Type"); case ColGroup: diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index d75ca07..5416903 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -249,9 +249,9 @@ void MainWindow::createActions() { m_stopAction->setToolTip(tr("Stop (Esc)")); connect(m_stopAction, &QAction::triggered, this, &MainWindow::stopPlayback); - m_previousCueAction = new QAction(Icons::mediaPrevious(), tr("&Previous Cue"), this); + m_previousCueAction = new QAction(Icons::mediaPrevious(), tr("&Back Cue"), this); m_previousCueAction->setShortcut(Qt::Key_Up); - m_previousCueAction->setToolTip(tr("Previous cue (Up)")); + m_previousCueAction->setToolTip(tr("Back cue (Up)")); connect(m_previousCueAction, &QAction::triggered, [this]() { m_app->playbackEngine()->previous(); }); @@ -486,25 +486,29 @@ void MainWindow::createMenus() { m_editMenu->addAction(m_undoAction); m_editMenu->addAction(m_redoAction); m_editMenu->addSeparator(); - m_editMenu->addAction(m_addCueAction); - m_editMenu->addAction(m_deleteCueAction); - m_editMenu->addAction(m_cloneCueAction); - m_editMenu->addAction(m_cloneToEndAction); - m_editMenu->addSeparator(); m_editMenu->addAction(m_copyCueAction); m_editMenu->addAction(m_pasteCueAction); m_editMenu->addAction(m_pasteMergeAction); m_editMenu->addAction(m_pasteSwapAction); + m_editMenu->addSeparator(); m_editMenu->addAction(m_fillDownAction); m_editMenu->addAction(m_cloneOffsetsAction); m_editMenu->addAction(m_recordOffsetsAction); m_editMenu->addSeparator(); - m_editMenu->addAction(m_renumberAction); - m_editMenu->addAction(m_jumpToSelectedAction); - m_editMenu->addAction(m_jumpAction); - m_editMenu->addSeparator(); m_editMenu->addAction(m_lockEditingAction); + // cue-management actions grouped into their own menu + QMenu* cueListMenu = menuBar()->addMenu(tr("Cue &List")); + cueListMenu->addAction(m_addCueAction); + cueListMenu->addAction(m_cloneCueAction); + cueListMenu->addAction(m_cloneToEndAction); + cueListMenu->addAction(m_deleteCueAction); + cueListMenu->addSeparator(); + cueListMenu->addAction(m_jumpToSelectedAction); + cueListMenu->addAction(m_jumpAction); + cueListMenu->addSeparator(); + cueListMenu->addAction(m_renumberAction); + m_playbackMenu = menuBar()->addMenu(tr("&Playback")); m_playbackMenu->addAction(m_goAction); m_playbackMenu->addAction(m_stopAction); From 611c229f6cd6085d8e6c3b7c3f810f457b730ca6 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 20:47:37 -0400 Subject: [PATCH 71/92] feat: TheatreMix-style cue grid columns (dot, Cue, Text, FX, Snip, QLab, DCA) --- src/ui/CueListView.cpp | 18 +++++++++++++----- src/ui/CueTableModel.cpp | 16 ++++++++++++++-- src/ui/CueTableModel.h | 19 ++++++++++++------- src/ui/MainWindow.cpp | 18 ++++++++++-------- 4 files changed, 49 insertions(+), 22 deletions(-) diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index a0f6881..20e3305 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -89,10 +89,16 @@ void CueListView::setupUi() { m_tableView->horizontalHeader()->setStretchLastSection(true); m_tableView->setShowGrid(false); - // column widths (ColNumber wider to fit the ▶/→ standby markers) + // column widths (Cue wider to fit the ▶/→ standby markers) + m_tableView->setColumnWidth(CueTableModel::ColColor, 28); m_tableView->setColumnWidth(CueTableModel::ColNumber, 78); - m_tableView->setColumnWidth(CueTableModel::ColName, 150); - m_tableView->setColumnWidth(CueTableModel::ColType, 100); + m_tableView->setColumnWidth(CueTableModel::ColName, 220); + m_tableView->setColumnWidth(CueTableModel::ColFx, 70); + m_tableView->setColumnWidth(CueTableModel::ColSnip, 70); + m_tableView->setColumnWidth(CueTableModel::ColExternal, 90); + m_tableView->setColumnWidth(CueTableModel::ColDca, 120); + m_tableView->setColumnWidth(CueTableModel::ColPosition, 80); + m_tableView->setColumnWidth(CueTableModel::ColType, 90); m_tableView->setColumnWidth(CueTableModel::ColGroup, 100); m_tableView->setColumnWidth(CueTableModel::ColTags, 120); m_tableView->setColumnWidth(CueTableModel::ColFade, 72); @@ -178,6 +184,8 @@ void CueListView::setupDelegates() { m_tableView->setItemDelegateForColumn(CueTableModel::ColDca, m_textDelegate); m_tableView->setItemDelegateForColumn(CueTableModel::ColPosition, m_textDelegate); m_tableView->setItemDelegateForColumn(CueTableModel::ColFx, m_textDelegate); + m_tableView->setItemDelegateForColumn(CueTableModel::ColSnip, m_textDelegate); + m_tableView->setItemDelegateForColumn(CueTableModel::ColExternal, m_textDelegate); m_tableView->setItemDelegateForColumn(CueTableModel::ColFade, m_textDelegate); // connect tab navigation @@ -546,7 +554,7 @@ void CueListView::showContextMenu(const QPoint& pos) { void CueListView::saveColumnWidths() { QSettings s; - s.beginGroup("CueListColumns"); + s.beginGroup("CueListColumnsV2"); for (int c = 0; c < CueTableModel::ColCount; ++c) s.setValue(QString::number(c), m_tableView->columnWidth(c)); s.endGroup(); @@ -554,7 +562,7 @@ void CueListView::saveColumnWidths() { void CueListView::restoreColumnWidths() { QSettings s; - s.beginGroup("CueListColumns"); + s.beginGroup("CueListColumnsV2"); for (int c = 0; c < CueTableModel::ColCount; ++c) { const int w = s.value(QString::number(c), 0).toInt(); if (w > 0) diff --git a/src/ui/CueTableModel.cpp b/src/ui/CueTableModel.cpp index 1286cc8..0f68b73 100644 --- a/src/ui/CueTableModel.cpp +++ b/src/ui/CueTableModel.cpp @@ -77,7 +77,15 @@ QVariant CueTableModel::data(const QModelIndex& index, int role) const { case ColNotes: return cue.notes(); case ColColor: - return cue.color(); + return QVariant(); // shown as a ● dot via the decoration role + case ColSnip: { + QStringList parts; + for (int s : cue.snippets()) + parts << QString::number(s); + return parts.join(", "); + } + case ColExternal: + return cue.qLabCue(); case ColDca: { QStringList parts; const QMap overrides = cue.dcaOverrides(); @@ -180,7 +188,11 @@ QVariant CueTableModel::headerData(int section, Qt::Orientation orientation, int case ColNotes: return tr("Notes"); case ColColor: - return tr("Color"); + return QString::fromUtf8("\xE2\x97\x8F"); // ● + case ColSnip: + return tr("Snip"); + case ColExternal: + return tr("QLab"); case ColDca: return tr("DCAs"); case ColPosition: diff --git a/src/ui/CueTableModel.h b/src/ui/CueTableModel.h index aa3cb37..eaf989e 100644 --- a/src/ui/CueTableModel.h +++ b/src/ui/CueTableModel.h @@ -14,18 +14,23 @@ class CueTableModel : public QAbstractTableModel { public: // ColColor is appended last so existing column indices stay stable for views // that assign per-column delegates/widths by name. + // Order mirrors the reference console layout: colour dot, cue number, text, + // then recall summaries; editing columns (Type/Group/Tags/Notes/Fade) trail + // and are hidden by default. enum Column { - ColNumber = 0, - ColName, + ColColor = 0, // ● cue colour dot + ColNumber, // Cue number + ColName, // Text (cue name) + ColFx, // read-only muted FX units + ColSnip, // read-only console snippet indices + ColExternal, // linked external-playback cue (QLab/SCS/Cue Player) + ColDca, // read-only summary of DCA overrides (channel labels) + ColPosition, // read-only count of positioned channels ColType, ColGroup, ColTags, ColNotes, - ColColor, - ColDca, // read-only summary of DCA overrides (channel labels) - ColPosition, // read-only count of positioned channels - ColFx, // read-only muted FX units - ColFade, // fade duration (instant / seconds) + ColFade, // fade duration (instant / seconds) ColCount }; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 5416903..8dd8471 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -560,14 +560,16 @@ void MainWindow::createMenus() { int column; bool visible; // default visibility }; - const ColumnToggle columnToggles[] = {{"&Group", CueTableModel::ColGroup, true}, - {"&Tags", CueTableModel::ColTags, true}, - {"&Notes", CueTableModel::ColNotes, true}, - {"F&ade", CueTableModel::ColFade, true}, - {"Colo&r", CueTableModel::ColColor, true}, - {"&DCAs", CueTableModel::ColDca, false}, - {"&Positions", CueTableModel::ColPosition, false}, - {"&FX", CueTableModel::ColFx, false}}; + const ColumnToggle columnToggles[] = {{"&FX", CueTableModel::ColFx, true}, + {"&Snip", CueTableModel::ColSnip, true}, + {"&QLab", CueTableModel::ColExternal, true}, + {"&DCAs", CueTableModel::ColDca, true}, + {"&Positions", CueTableModel::ColPosition, true}, + {"T&ype", CueTableModel::ColType, false}, + {"&Group", CueTableModel::ColGroup, false}, + {"&Tags", CueTableModel::ColTags, false}, + {"&Notes", CueTableModel::ColNotes, false}, + {"Fa&de", CueTableModel::ColFade, false}}; for (const ColumnToggle& ct : columnToggles) { QAction* action = columnsMenu->addAction(tr(ct.label)); action->setCheckable(true); From 89bcedfc2fdac680cc08b8ba045c79d091b05c19 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 21:03:19 -0400 Subject: [PATCH 72/92] feat: per-DCA triplet columns [DCA n | fx | pos] with FX/Pos column toggles --- src/ui/CueListView.cpp | 22 ++++++++++--- src/ui/CueListView.h | 1 + src/ui/CueTableModel.cpp | 69 +++++++++++++++++++++++++++++----------- src/ui/CueTableModel.h | 15 +++++++-- src/ui/MainWindow.cpp | 12 +++++-- 5 files changed, 93 insertions(+), 26 deletions(-) diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index 20e3305..d517118 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -96,13 +96,19 @@ void CueListView::setupUi() { m_tableView->setColumnWidth(CueTableModel::ColFx, 70); m_tableView->setColumnWidth(CueTableModel::ColSnip, 70); m_tableView->setColumnWidth(CueTableModel::ColExternal, 90); - m_tableView->setColumnWidth(CueTableModel::ColDca, 120); - m_tableView->setColumnWidth(CueTableModel::ColPosition, 80); m_tableView->setColumnWidth(CueTableModel::ColType, 90); m_tableView->setColumnWidth(CueTableModel::ColGroup, 100); m_tableView->setColumnWidth(CueTableModel::ColTags, 120); m_tableView->setColumnWidth(CueTableModel::ColFade, 72); + // per-DCA triplet columns: narrow, and hide the fx/pos sub-columns by default + for (int c = CueTableModel::ColCount; c < m_model->columnCount(); ++c) { + const int sub = m_model->dcaSubColumn(c); + m_tableView->setColumnWidth(c, sub == 0 ? 60 : 40); + if (sub == 1 || sub == 2) + m_tableView->setColumnHidden(c, true); + } + // columns are user-resizable; remember widths across sessions m_tableView->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive); restoreColumnWidths(); @@ -171,6 +177,10 @@ void CueListView::setupDelegates() { m_typeDelegate = new CueTypeDelegate(this); m_textDelegate = new CueTextDelegate(this); + // default delegate covers the dynamic per-DCA columns (strips the selection + // block so the row's standby/current colour shows through) + m_tableView->setItemDelegate(m_textDelegate); + // assign delegates to columns m_tableView->setItemDelegateForColumn(CueTableModel::ColNumber, m_numberDelegate); m_tableView->setItemDelegateForColumn(CueTableModel::ColName, m_textDelegate); @@ -181,8 +191,6 @@ void CueListView::setupDelegates() { // remaining columns: honor the row's standby/current background instead of // painting a selection block over it m_tableView->setItemDelegateForColumn(CueTableModel::ColColor, m_textDelegate); - m_tableView->setItemDelegateForColumn(CueTableModel::ColDca, m_textDelegate); - m_tableView->setItemDelegateForColumn(CueTableModel::ColPosition, m_textDelegate); m_tableView->setItemDelegateForColumn(CueTableModel::ColFx, m_textDelegate); m_tableView->setItemDelegateForColumn(CueTableModel::ColSnip, m_textDelegate); m_tableView->setItemDelegateForColumn(CueTableModel::ColExternal, m_textDelegate); @@ -503,6 +511,12 @@ void CueListView::setColumnVisible(int column, bool visible) { m_tableView->setColumnHidden(column, !visible); } +void CueListView::setDcaSubColumnsVisible(int sub, bool visible) { + for (int c = CueTableModel::ColCount; c < m_model->columnCount(); ++c) + if (m_model->dcaSubColumn(c) == sub) + m_tableView->setColumnHidden(c, !visible); +} + void CueListView::setEditingLocked(bool locked) { m_tableView->setEditTriggers(locked ? QAbstractItemView::NoEditTriggers : QAbstractItemView::DoubleClicked | diff --git a/src/ui/CueListView.h b/src/ui/CueListView.h index 9062d58..31fed32 100644 --- a/src/ui/CueListView.h +++ b/src/ui/CueListView.h @@ -53,6 +53,7 @@ class CueListView : public QWidget { void setEditingLocked(bool locked); // make the cue table read-only void setRowHeight(int pixels); // cue-table row height void setColumnVisible(int column, bool visible); + void setDcaSubColumnsVisible(int sub, bool visible); // sub 1=fx, 2=pos [[nodiscard]] bool hasClipboardCue() const { return m_clipboard.has_value(); } diff --git a/src/ui/CueTableModel.cpp b/src/ui/CueTableModel.cpp index 0f68b73..5a4d3f5 100644 --- a/src/ui/CueTableModel.cpp +++ b/src/ui/CueTableModel.cpp @@ -38,7 +38,28 @@ int CueTableModel::rowCount(const QModelIndex& parent) const { int CueTableModel::columnCount(const QModelIndex& parent) const { if (parent.isValid()) return 0; - return ColCount; + return ColCount + m_dcaCount * DcaSubCols; +} + +void CueTableModel::setDcaCount(int count) { + count = qBound(0, count, 64); + if (count == m_dcaCount) + return; + beginResetModel(); + m_dcaCount = count; + endResetModel(); +} + +int CueTableModel::dcaSubColumn(int col) const { + if (col < ColCount || col >= ColCount + m_dcaCount * DcaSubCols) + return -1; + return (col - ColCount) % DcaSubCols; +} + +int CueTableModel::dcaOfColumn(int col) const { + if (col < ColCount || col >= ColCount + m_dcaCount * DcaSubCols) + return -1; + return (col - ColCount) / DcaSubCols + 1; // 1-based } QVariant CueTableModel::data(const QModelIndex& index, int role) const { @@ -54,6 +75,22 @@ QVariant CueTableModel::data(const QModelIndex& index, int role) const { const Cue& cue = m_cueList->at(row); if (role == Qt::DisplayRole || role == Qt::EditRole) { + // per-DCA triplet columns: [assignment | fx | pos] + const int sub = dcaSubColumn(col); + if (sub >= 0) { + const int dca = dcaOfColumn(col); + const DCAOverride ov = cue.dcaOverride(dca); + if (sub == 0) { // assignment: the cue's DCA label / mute override + if (ov.label.has_value()) + return *ov.label; + if (ov.mute.has_value()) + return *ov.mute ? tr("mute") : tr("on"); + } + // sub 1 (fx) and sub 2 (pos) are per-DCA-channel data OpenMix does + // not yet model at this layer; the columns exist and are toggleable + return QString(); + } + switch (col) { case ColNumber: { const QString num = QString::number(cue.number(), 'f', 1); @@ -86,19 +123,6 @@ QVariant CueTableModel::data(const QModelIndex& index, int role) const { } case ColExternal: return cue.qLabCue(); - case ColDca: { - QStringList parts; - const QMap overrides = cue.dcaOverrides(); - for (auto it = overrides.begin(); it != overrides.end(); ++it) { - if (it.value().label.has_value()) - parts << QString("%1:%2").arg(it.key()).arg(*it.value().label); - } - return parts.join(", "); - } - case ColPosition: { - const int count = cue.channelPositions().size(); - return count > 0 ? tr("%n ch", "", count) : QString(); - } case ColFx: { QStringList parts; const QMap mutes = cue.fxMutes(); @@ -174,6 +198,19 @@ QVariant CueTableModel::headerData(int section, Qt::Orientation orientation, int return QVariant(); } + // per-DCA triplet headers + const int sub = dcaSubColumn(section); + if (sub >= 0) { + switch (sub) { + case 0: + return tr("DCA %1").arg(dcaOfColumn(section)); + case 1: + return tr("fx"); + case 2: + return tr("pos"); + } + } + switch (section) { case ColNumber: return tr("Cue"); @@ -193,10 +230,6 @@ QVariant CueTableModel::headerData(int section, Qt::Orientation orientation, int return tr("Snip"); case ColExternal: return tr("QLab"); - case ColDca: - return tr("DCAs"); - case ColPosition: - return tr("Positions"); case ColFx: return tr("FX"); case ColFade: diff --git a/src/ui/CueTableModel.h b/src/ui/CueTableModel.h index eaf989e..396813c 100644 --- a/src/ui/CueTableModel.h +++ b/src/ui/CueTableModel.h @@ -17,6 +17,10 @@ class CueTableModel : public QAbstractTableModel { // Order mirrors the reference console layout: colour dot, cue number, text, // then recall summaries; editing columns (Type/Group/Tags/Notes/Fade) trail // and are hidden by default. + // Fixed columns first; the per-DCA triplet columns [DCA n | fx | pos] are + // appended dynamically after ColCount (see columnCount()). The trailing + // fixed columns (Type..Fade) are hidden by default, so the visible order is + // colour, Cue, Text, FX, Snip, QLab, then the DCA triplets. enum Column { ColColor = 0, // ● cue colour dot ColNumber, // Cue number @@ -24,8 +28,6 @@ class CueTableModel : public QAbstractTableModel { ColFx, // read-only muted FX units ColSnip, // read-only console snippet indices ColExternal, // linked external-playback cue (QLab/SCS/Cue Player) - ColDca, // read-only summary of DCA overrides (channel labels) - ColPosition, // read-only count of positioned channels ColType, ColGroup, ColTags, @@ -34,6 +36,14 @@ class CueTableModel : public QAbstractTableModel { ColCount }; + // per-DCA columns appended after the fixed ones + static constexpr int DcaSubCols = 3; // [assignment | fx | pos] + [[nodiscard]] int dcaCount() const { return m_dcaCount; } + void setDcaCount(int count); + // sub-column 0=assignment, 1=fx, 2=pos, or -1 if col is not a DCA column + [[nodiscard]] int dcaSubColumn(int col) const; + [[nodiscard]] int dcaOfColumn(int col) const; // 1-based DCA, or -1 + explicit CueTableModel(CueList* cueList, QObject* parent = nullptr); // qAbstractTableModel interface @@ -85,6 +95,7 @@ class CueTableModel : public QAbstractTableModel { CueList* m_cueList; int m_currentIndex = -1; int m_standbyIndex = -1; + int m_dcaCount = 8; static const QString s_mimeType; }; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 8dd8471..587f390 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -563,8 +563,6 @@ void MainWindow::createMenus() { const ColumnToggle columnToggles[] = {{"&FX", CueTableModel::ColFx, true}, {"&Snip", CueTableModel::ColSnip, true}, {"&QLab", CueTableModel::ColExternal, true}, - {"&DCAs", CueTableModel::ColDca, true}, - {"&Positions", CueTableModel::ColPosition, true}, {"T&ype", CueTableModel::ColType, false}, {"&Group", CueTableModel::ColGroup, false}, {"&Tags", CueTableModel::ColTags, false}, @@ -579,6 +577,16 @@ void MainWindow::createMenus() { connect(action, &QAction::toggled, this, [this, column](bool on) { m_cueListView->setColumnVisible(column, on); }); } + columnsMenu->addSeparator(); + // per-DCA fx / pos sub-columns (off by default, mirroring the console app) + QAction* fxCols = columnsMenu->addAction(tr("DCA F&X Columns")); + fxCols->setCheckable(true); + connect(fxCols, &QAction::toggled, this, + [this](bool on) { m_cueListView->setDcaSubColumnsVisible(1, on); }); + QAction* posCols = columnsMenu->addAction(tr("DCA P&os Columns")); + posCols->setCheckable(true); + connect(posCols, &QAction::toggled, this, + [this](bool on) { m_cueListView->setDcaSubColumnsVisible(2, on); }); m_viewMenu->addSeparator(); m_viewMenu->addAction(m_cueZeroAction); m_viewMenu->addSeparator(); From 267d05b59d03e6f82fc57b86e14614f36f4930dc Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 21:23:00 -0400 Subject: [PATCH 73/92] feat: consolidate to TheatreMix 5-menu bar (File/Edit/View/Cue List/Help) --- src/ui/MainWindow.cpp | 56 ++++++++++++++++++------------------------- 1 file changed, 23 insertions(+), 33 deletions(-) diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 587f390..ab5f1e0 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -497,31 +497,6 @@ void MainWindow::createMenus() { m_editMenu->addSeparator(); m_editMenu->addAction(m_lockEditingAction); - // cue-management actions grouped into their own menu - QMenu* cueListMenu = menuBar()->addMenu(tr("Cue &List")); - cueListMenu->addAction(m_addCueAction); - cueListMenu->addAction(m_cloneCueAction); - cueListMenu->addAction(m_cloneToEndAction); - cueListMenu->addAction(m_deleteCueAction); - cueListMenu->addSeparator(); - cueListMenu->addAction(m_jumpToSelectedAction); - cueListMenu->addAction(m_jumpAction); - cueListMenu->addSeparator(); - cueListMenu->addAction(m_renumberAction); - - m_playbackMenu = menuBar()->addMenu(tr("&Playback")); - m_playbackMenu->addAction(m_goAction); - m_playbackMenu->addAction(m_stopAction); - m_playbackMenu->addSeparator(); - m_playbackMenu->addAction(m_previousCueAction); - m_playbackMenu->addAction(m_nextCueAction); - m_playbackMenu->addSeparator(); - m_playbackMenu->addAction(m_panicAction); - m_playbackMenu->addAction(m_panicRestoreAction); - m_playbackMenu->addSeparator(); - m_playbackMenu->addAction(m_spareBackupAction); - m_playbackMenu->addAction(m_recordFadersAction); - m_viewMenu = menuBar()->addMenu(tr("&View")); m_viewMenu->addAction(m_showDCAMappingAction); m_viewMenu->addAction(m_showCueEditorAction); @@ -596,18 +571,33 @@ void MainWindow::createMenus() { m_viewMenu->addAction(m_exportCsvAction); m_viewMenu->addAction(m_showLogViewerAction); - m_settingsMenu = menuBar()->addMenu(tr("&Settings")); - m_settingsMenu->addAction(m_keyboardShortcutsAction); - m_settingsMenu->addAction(m_midiControllerAction); - m_settingsMenu->addAction(m_remoteControlAction); - m_settingsMenu->addSeparator(); - m_settingsMenu->addAction(m_fxSetupAction); - m_settingsMenu->addAction(m_appSettingsAction); + // cue-management actions grouped into their own menu (after View) + QMenu* cueListMenu = menuBar()->addMenu(tr("Cue &List")); + cueListMenu->addAction(m_addCueAction); + cueListMenu->addAction(m_cloneCueAction); + cueListMenu->addAction(m_cloneToEndAction); + cueListMenu->addAction(m_deleteCueAction); + cueListMenu->addSeparator(); + cueListMenu->addAction(m_jumpToSelectedAction); + cueListMenu->addAction(m_jumpAction); + cueListMenu->addSeparator(); + cueListMenu->addAction(m_renumberAction); + // Help folds in setup, configuration and the remaining playback actions so + // the menu bar stays at five top-level menus m_helpMenu = menuBar()->addMenu(tr("&Help")); + m_helpMenu->addAction(m_spareBackupAction); + m_helpMenu->addAction(m_recordFadersAction); + m_helpMenu->addAction(m_panicRestoreAction); + m_helpMenu->addSeparator(); + m_helpMenu->addAction(m_remoteControlAction); + m_helpMenu->addAction(m_midiControllerAction); + m_helpMenu->addAction(m_fxSetupAction); + m_helpMenu->addAction(m_keyboardShortcutsAction); + m_helpMenu->addAction(m_appSettingsAction); + m_helpMenu->addSeparator(); m_helpMenu->addAction(m_quickStartAction); m_helpMenu->addAction(m_featureGuideAction); - m_helpMenu->addSeparator(); m_helpMenu->addAction(m_aboutAction); } From 9bde1998a0f5713ee699d0b1c7a0bb838c23726b Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 21:28:02 -0400 Subject: [PATCH 74/92] feat: per-channel status strip (tiles colored by monitor state), mixer feedback to pop-out --- CMakeLists.txt | 2 + src/ui/ChannelStripPanel.cpp | 89 ++++++++++++++++++++++++++++++++++++ src/ui/ChannelStripPanel.h | 35 ++++++++++++++ src/ui/MainWindow.cpp | 31 +++++++++---- src/ui/MainWindow.h | 4 ++ 5 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 src/ui/ChannelStripPanel.cpp create mode 100644 src/ui/ChannelStripPanel.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 357523c..9bcf8a7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -114,6 +114,7 @@ set(SOURCES src/core/AppLogger.cpp src/core/ConnectionLogBridge.cpp src/ui/MainWindow.cpp + src/ui/ChannelStripPanel.cpp src/ui/CueListView.cpp src/ui/CueEditor.cpp src/ui/CollapsibleSection.cpp @@ -231,6 +232,7 @@ set(HEADERS src/core/AppLogger.h src/core/ConnectionLogBridge.h src/ui/MainWindow.h + src/ui/ChannelStripPanel.h src/ui/CueListView.h src/ui/CueEditor.h src/ui/CollapsibleSection.h diff --git a/src/ui/ChannelStripPanel.cpp b/src/ui/ChannelStripPanel.cpp new file mode 100644 index 0000000..e5b649c --- /dev/null +++ b/src/ui/ChannelStripPanel.cpp @@ -0,0 +1,89 @@ +#include "ChannelStripPanel.h" + +#include "app/Application.h" +#include "core/Actor.h" +#include "core/ActorProfileLibrary.h" +#include "core/ChannelMonitor.h" +#include "core/Show.h" +#include "theme/Theme.h" + +#include +#include + +namespace OpenMix { + +namespace { +constexpr int kColumns = 12; // wrap the channel tiles into rows of this many +} + +ChannelStripPanel::ChannelStripPanel(Application* app, QWidget* parent) + : QWidget(parent), m_app(app) { + m_grid = new QGridLayout(this); + m_grid->setContentsMargins(Theme::SpacingS, Theme::SpacingS, Theme::SpacingS, Theme::SpacingS); + m_grid->setSpacing(Theme::SpacingXS); + + m_emptyHint = new QLabel(tr("No actor channels assigned — set them up in Actor Setup."), this); + m_emptyHint->setStyleSheet(QString("color: %1;").arg(Theme::Colors::TextTertiary)); + m_grid->addWidget(m_emptyHint, 0, 0); + + if (m_app && m_app->show() && m_app->show()->actorProfileLibrary()) + connect(m_app->show()->actorProfileLibrary(), &ActorProfileLibrary::changed, this, + &ChannelStripPanel::rebuild); + if (m_app && m_app->channelMonitor()) + connect(m_app->channelMonitor(), &ChannelMonitor::channelStateChanged, this, + &ChannelStripPanel::onChannelStateChanged); + + rebuild(); +} + +void ChannelStripPanel::rebuild() { + for (QLabel* tile : m_tiles) + tile->deleteLater(); + m_tiles.clear(); + + if (!m_app || !m_app->show() || !m_app->show()->actorProfileLibrary()) + return; + + const QList& actors = m_app->show()->actorProfileLibrary()->actors(); + m_emptyHint->setVisible(actors.isEmpty()); + + int i = 0; + for (const Actor& actor : actors) { + const int channel = actor.channel(); + if (channel <= 0) + continue; + auto* tile = new QLabel(this); + tile->setAlignment(Qt::AlignCenter); + tile->setFixedSize(84, 44); + const QString name = actor.name().isEmpty() ? tr("ch %1").arg(channel) : actor.name(); + tile->setText(QString("%1
%2").arg(channel).arg(name.toHtmlEscaped())); + const int state = m_app->channelMonitor() + ? static_cast(m_app->channelMonitor()->channelState(channel)) + : 0; + styleTile(tile, state); + m_grid->addWidget(tile, i / kColumns, i % kColumns); + m_tiles.insert(channel, tile); + ++i; + } +} + +void ChannelStripPanel::onChannelStateChanged(int channel, int state) { + if (QLabel* tile = m_tiles.value(channel)) + styleTile(tile, state); +} + +void ChannelStripPanel::styleTile(QLabel* tile, int state) { + // Normal / Silent / Clipping + const char* border = Theme::Colors::AccentGreen; + if (state == static_cast(ChannelState::Silent)) + border = Theme::Colors::AccentBlue; + else if (state == static_cast(ChannelState::Clipping)) + border = Theme::Colors::AccentRed; + tile->setStyleSheet(QString("QLabel { background: %1; border: 2px solid %2; border-radius: 4px; " + "color: %3; padding: 2px; }") + .arg(Theme::Colors::BgElevated) + .arg(border) + .arg(Theme::Colors::TextPrimary)); +} + +} // namespace OpenMix diff --git a/src/ui/ChannelStripPanel.h b/src/ui/ChannelStripPanel.h new file mode 100644 index 0000000..4fd5bd3 --- /dev/null +++ b/src/ui/ChannelStripPanel.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +class QGridLayout; +class QLabel; + +namespace OpenMix { + +class Application; + +// A horizontally-wrapping strip of per-channel status tiles beneath the cue +// grid: one tile per assigned actor channel, showing the channel number and +// name, coloured by the channel monitor's silence/clip state. +class ChannelStripPanel : public QWidget { + Q_OBJECT + + public: + explicit ChannelStripPanel(Application* app, QWidget* parent = nullptr); + + private slots: + void rebuild(); + void onChannelStateChanged(int channel, int state); + + private: + void styleTile(QLabel* tile, int state); + + Application* m_app; + QGridLayout* m_grid; + QLabel* m_emptyHint = nullptr; + QHash m_tiles; // channel -> tile +}; + +} // namespace OpenMix diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index ab5f1e0..8777f48 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -2,6 +2,7 @@ #include "ActorSetupPanel.h" #include "BubbleBar.h" #include "BubbleButton.h" +#include "ChannelStripPanel.h" #include "ConnectionPanel.h" #include "CueEditor.h" #include "ActiveCueInfoPanel.h" @@ -103,11 +104,11 @@ void MainWindow::setupUi() { m_cueListView = new CueListView(m_app, this); m_cueEditor = new CueEditor(m_app, this); // parented to a pop-out below - m_mixerFeedbackPanel = new MixerFeedbackPanel(m_app, this); - m_mixerFeedbackPanel->setMaximumHeight(240); + m_channelStrip = new ChannelStripPanel(m_app, this); + m_channelStrip->setMaximumHeight(240); m_mainSplitter->addWidget(m_cueListView); - m_mainSplitter->addWidget(m_mixerFeedbackPanel); + m_mainSplitter->addWidget(m_channelStrip); m_mainSplitter->setStretchFactor(0, 5); m_mainSplitter->setStretchFactor(1, 1); m_mainSplitter->setChildrenCollapsible(false); @@ -295,7 +296,7 @@ void MainWindow::createActions() { m_showMixerFeedbackAction->setShortcut(Qt::Key_F6); m_showMixerFeedbackAction->setToolTip(tr("Show/hide the channel-status strip (F6)")); connect(m_showMixerFeedbackAction, &QAction::triggered, this, - &MainWindow::toggleMixerFeedbackPanel); + &MainWindow::toggleChannelStrip); m_showCueEditorAction = new QAction(Icons::sliders(), tr("Cue &Editor"), this); m_showCueEditorAction->setCheckable(true); @@ -714,6 +715,15 @@ void MainWindow::createPopOutWindows() { connect(m_cueEditorPopOut, &PopOutWindow::visibilityChanged, [this](bool visible) { m_showCueEditorAction->setChecked(visible); }); + // DCA fader feedback is a pop-out (the bottom strip shows per-channel status) + m_mixerFeedbackPanel = new MixerFeedbackPanel(m_app, nullptr); + m_mixerFeedbackPopOut = new PopOutWindow("mixerFeedback", tr("Mixer Feedback"), this); + m_mixerFeedbackPopOut->setContentWidget(m_mixerFeedbackPanel); + m_mixerFeedbackPopOut->setMinimumContentSize(400, 350); + + connect(m_mixerFeedbackPopOut, &PopOutWindow::visibilityChanged, + [this](bool visible) { m_bubbleBar->setButtonActive("mixer", visible); }); + m_dcaMappingPanel = new DCAMappingPanel(m_app, nullptr); m_dcaMappingPopOut = new PopOutWindow("dcaMapping", tr("DCA Mapping"), this); m_dcaMappingPopOut->setContentWidget(m_dcaMappingPanel); @@ -1168,11 +1178,16 @@ void MainWindow::toggleConnectionPanel() { } void MainWindow::toggleMixerFeedbackPanel() { - // the mixer feedback is the bottom channel-status strip; toggle its visibility - const bool show = !m_mixerFeedbackPanel->isVisible(); - m_mixerFeedbackPanel->setVisible(show); + if (m_mixerFeedbackPopOut->isVisible()) + m_mixerFeedbackPopOut->hide(); + else + m_mixerFeedbackPopOut->showAndRestore(); +} + +void MainWindow::toggleChannelStrip() { + const bool show = !m_channelStrip->isVisible(); + m_channelStrip->setVisible(show); m_showMixerFeedbackAction->setChecked(show); - m_bubbleBar->setButtonActive("mixer", show); } void MainWindow::toggleCueEditorPanel() { diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index b77e309..70cab98 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -15,6 +15,7 @@ namespace OpenMix { class Application; class CueListView; class CueEditor; +class ChannelStripPanel; class ConnectionPanel; class MixerFeedbackPanel; class DCAMappingPanel; @@ -70,6 +71,7 @@ class MainWindow : public QMainWindow { void toggleConnectionPanel(); void toggleMixerFeedbackPanel(); void toggleCueEditorPanel(); + void toggleChannelStrip(); void toggleDCAMappingPanel(); void toggleActorSetupPanel(); void toggleEnsemblePanel(); @@ -137,6 +139,7 @@ class MainWindow : public QMainWindow { // pop-out window contents (owned by pop-out windows) ConnectionPanel* m_connectionPanel; MixerFeedbackPanel* m_mixerFeedbackPanel; + ChannelStripPanel* m_channelStrip; DCAMappingPanel* m_dcaMappingPanel; ActorSetupPanel* m_actorSetupPanel; EnsemblePanel* m_ensemblePanel; @@ -147,6 +150,7 @@ class MainWindow : public QMainWindow { // pop-out windows PopOutWindow* m_connectionPopOut; PopOutWindow* m_cueEditorPopOut; + PopOutWindow* m_mixerFeedbackPopOut; PopOutWindow* m_dcaMappingPopOut; PopOutWindow* m_actorSetupPopOut; PopOutWindow* m_ensemblePopOut; From 0c1e60cfb3a94a423b4dab41fdd3ed2f49bd6fa5 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 21:31:30 -0400 Subject: [PATCH 75/92] feat: add scenes + per-channel FX to cue model with persistence and Scene column --- src/core/Cue.cpp | 33 +++++++++++++++++++++++++++++++++ src/core/Cue.h | 17 +++++++++++++++++ src/ui/CueListView.cpp | 2 ++ src/ui/CueTableModel.cpp | 8 ++++++++ src/ui/CueTableModel.h | 1 + src/ui/MainWindow.cpp | 1 + 6 files changed, 62 insertions(+) diff --git a/src/core/Cue.cpp b/src/core/Cue.cpp index f8e5280..1a4eee1 100644 --- a/src/core/Cue.cpp +++ b/src/core/Cue.cpp @@ -241,6 +241,11 @@ void Cue::mergeContentFrom(const Cue& other) { for (int snippet : other.m_snippets) if (!m_snippets.contains(snippet)) m_snippets.append(snippet); + for (int scene : other.m_scenes) + if (!m_scenes.contains(scene)) + m_scenes.append(scene); + for (auto it = other.m_channelFX.begin(); it != other.m_channelFX.end(); ++it) + m_channelFX.insert(it.key(), it.value()); // parameter bag: other's keys overlay for (auto it = other.m_parameters.begin(); it != other.m_parameters.end(); ++it) @@ -272,6 +277,8 @@ void Cue::swapContentWith(Cue& other) { std::swap(m_channelLevels, other.m_channelLevels); std::swap(m_fxMutes, other.m_fxMutes); std::swap(m_snippets, other.m_snippets); + std::swap(m_scenes, other.m_scenes); + std::swap(m_channelFX, other.m_channelFX); std::swap(m_color, other.m_color); std::swap(m_skip, other.m_skip); } @@ -414,6 +421,20 @@ QJsonObject Cue::toJson() const { json["snippets"] = snippetArray; } + if (!m_scenes.isEmpty()) { + QJsonArray sceneArray; + for (int scene : m_scenes) + sceneArray.append(scene); + json["scenes"] = sceneArray; + } + + if (!m_channelFX.isEmpty()) { + QJsonObject fxChanObj; + for (auto it = m_channelFX.constBegin(); it != m_channelFX.constEnd(); ++it) + fxChanObj[QString::number(it.key())] = it.value(); + json["channelFX"] = fxChanObj; + } + if (!m_color.isEmpty()) { json["color"] = m_color; } @@ -550,6 +571,18 @@ Cue Cue::fromJson(const QJsonObject& json) { } } + if (json.contains("scenes")) { + const QJsonArray sceneArray = json["scenes"].toArray(); + for (const QJsonValue& val : sceneArray) + cue.m_scenes.append(val.toInt()); + } + + if (json.contains("channelFX")) { + const QJsonObject fxChanObj = json["channelFX"].toObject(); + for (auto it = fxChanObj.constBegin(); it != fxChanObj.constEnd(); ++it) + cue.m_channelFX[it.key().toInt()] = it.value().toBool(); + } + cue.m_color = json["color"].toString(); cue.m_skip = json["skip"].toBool(false); diff --git a/src/core/Cue.h b/src/core/Cue.h index d67f7dd..198e818 100644 --- a/src/core/Cue.h +++ b/src/core/Cue.h @@ -196,6 +196,21 @@ class Cue { } void removeSnippet(int snippet) { m_snippets.removeAll(snippet); } + // console scene numbers recalled on fire + [[nodiscard]] QList scenes() const { return m_scenes; } + void setScenes(const QList& scenes) { m_scenes = scenes; } + void addScene(int scene) { + if (!m_scenes.contains(scene)) + m_scenes.append(scene); + } + void removeScene(int scene) { m_scenes.removeAll(scene); } + + // per-channel FX-on state applied on fire (channel -> fx active) + [[nodiscard]] QMap channelFX() const { return m_channelFX; } + void setChannelFX(const QMap& fx) { m_channelFX = fx; } + void setChannelFX(int channel, bool active) { m_channelFX[channel] = active; } + void removeChannelFX(int channel) { m_channelFX.remove(channel); } + // display color (hex string, e.g. "#ff0000"); empty = list default [[nodiscard]] QString color() const { return m_color; } void setColor(const QString& color) { m_color = color; } @@ -263,6 +278,8 @@ class Cue { 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 }; diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index d517118..093f081 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -94,6 +94,7 @@ void CueListView::setupUi() { m_tableView->setColumnWidth(CueTableModel::ColNumber, 78); m_tableView->setColumnWidth(CueTableModel::ColName, 220); m_tableView->setColumnWidth(CueTableModel::ColFx, 70); + m_tableView->setColumnWidth(CueTableModel::ColScene, 70); m_tableView->setColumnWidth(CueTableModel::ColSnip, 70); m_tableView->setColumnWidth(CueTableModel::ColExternal, 90); m_tableView->setColumnWidth(CueTableModel::ColType, 90); @@ -192,6 +193,7 @@ void CueListView::setupDelegates() { // painting a selection block over it m_tableView->setItemDelegateForColumn(CueTableModel::ColColor, m_textDelegate); m_tableView->setItemDelegateForColumn(CueTableModel::ColFx, m_textDelegate); + m_tableView->setItemDelegateForColumn(CueTableModel::ColScene, m_textDelegate); m_tableView->setItemDelegateForColumn(CueTableModel::ColSnip, m_textDelegate); m_tableView->setItemDelegateForColumn(CueTableModel::ColExternal, m_textDelegate); m_tableView->setItemDelegateForColumn(CueTableModel::ColFade, m_textDelegate); diff --git a/src/ui/CueTableModel.cpp b/src/ui/CueTableModel.cpp index 5a4d3f5..611981d 100644 --- a/src/ui/CueTableModel.cpp +++ b/src/ui/CueTableModel.cpp @@ -115,6 +115,12 @@ QVariant CueTableModel::data(const QModelIndex& index, int role) const { return cue.notes(); case ColColor: return QVariant(); // shown as a ● dot via the decoration role + case ColScene: { + QStringList parts; + for (int s : cue.scenes()) + parts << QString::number(s); + return parts.join(", "); + } case ColSnip: { QStringList parts; for (int s : cue.snippets()) @@ -226,6 +232,8 @@ QVariant CueTableModel::headerData(int section, Qt::Orientation orientation, int return tr("Notes"); case ColColor: return QString::fromUtf8("\xE2\x97\x8F"); // ● + case ColScene: + return tr("Scene"); case ColSnip: return tr("Snip"); case ColExternal: diff --git a/src/ui/CueTableModel.h b/src/ui/CueTableModel.h index 396813c..7a43d01 100644 --- a/src/ui/CueTableModel.h +++ b/src/ui/CueTableModel.h @@ -26,6 +26,7 @@ class CueTableModel : public QAbstractTableModel { ColNumber, // Cue number ColName, // Text (cue name) ColFx, // read-only muted FX units + ColScene, // read-only console scene numbers ColSnip, // read-only console snippet indices ColExternal, // linked external-playback cue (QLab/SCS/Cue Player) ColType, diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 8777f48..79bf79a 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -537,6 +537,7 @@ void MainWindow::createMenus() { bool visible; // default visibility }; const ColumnToggle columnToggles[] = {{"&FX", CueTableModel::ColFx, true}, + {"S&cene", CueTableModel::ColScene, true}, {"&Snip", CueTableModel::ColSnip, true}, {"&QLab", CueTableModel::ColExternal, true}, {"T&ype", CueTableModel::ColType, false}, From a312a9b9407d5e07bfcc539188ab76b6e37b1cf0 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 21:33:18 -0400 Subject: [PATCH 76/92] feat: populate per-DCA fx/pos columns from channelFX/positions via DCA mapping --- src/ui/CueListView.cpp | 1 + src/ui/CueTableModel.cpp | 25 +++++++++++++++++++++++-- src/ui/CueTableModel.h | 5 +++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index 093f081..453b768 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -56,6 +56,7 @@ void CueListView::setupUi() { // create model & proxy m_model = new CueTableModel(m_app->show()->cueList(), this); + m_model->setDcaMapping(m_app->show()->dcaMapping()); m_proxyModel = new CueFilterProxyModel(this); m_proxyModel->setSourceModel(m_model); diff --git a/src/ui/CueTableModel.cpp b/src/ui/CueTableModel.cpp index 611981d..77ebec9 100644 --- a/src/ui/CueTableModel.cpp +++ b/src/ui/CueTableModel.cpp @@ -1,6 +1,7 @@ #include "CueTableModel.h" #include "core/Cue.h" #include "core/CueList.h" +#include "core/DCAMapping.h" #include "theme/Theme.h" #include #include @@ -85,9 +86,29 @@ QVariant CueTableModel::data(const QModelIndex& index, int role) const { return *ov.label; if (ov.mute.has_value()) return *ov.mute ? tr("mute") : tr("on"); + return QString(); + } + // fx / pos: summarise the cue's per-channel FX / position over the + // channels assigned to this DCA (via the show's DCA mapping) + if (m_dcaMapping) { + const QList channels = m_dcaMapping->channelsForDCA(dca); + if (sub == 1) { // fx + const QMap fx = cue.channelFX(); + int n = 0; + for (int ch : channels) + if (fx.value(ch, false)) + ++n; + return n > 0 ? QString::number(n) : QString(); + } + if (sub == 2) { // pos + const QMap pos = cue.channelPositions(); + int n = 0; + for (int ch : channels) + if (!pos.value(ch).isEmpty()) + ++n; + return n > 0 ? QString::number(n) : QString(); + } } - // sub 1 (fx) and sub 2 (pos) are per-DCA-channel data OpenMix does - // not yet model at this layer; the columns exist and are toggleable return QString(); } diff --git a/src/ui/CueTableModel.h b/src/ui/CueTableModel.h index 7a43d01..3ec2816 100644 --- a/src/ui/CueTableModel.h +++ b/src/ui/CueTableModel.h @@ -7,6 +7,7 @@ namespace OpenMix { class CueList; class Cue; +class DCAMapping; class CueTableModel : public QAbstractTableModel { Q_OBJECT @@ -45,6 +46,9 @@ class CueTableModel : public QAbstractTableModel { [[nodiscard]] int dcaSubColumn(int col) const; [[nodiscard]] int dcaOfColumn(int col) const; // 1-based DCA, or -1 + // show-level DCA→channel mapping, used to fill per-DCA fx/pos columns + void setDcaMapping(DCAMapping* mapping) { m_dcaMapping = mapping; } + explicit CueTableModel(CueList* cueList, QObject* parent = nullptr); // qAbstractTableModel interface @@ -97,6 +101,7 @@ class CueTableModel : public QAbstractTableModel { int m_currentIndex = -1; int m_standbyIndex = -1; int m_dcaCount = 8; + DCAMapping* m_dcaMapping = nullptr; static const QString s_mimeType; }; From 634f40e52edb26f0102b06a92c0a4b1012d5fde4 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 21:36:04 -0400 Subject: [PATCH 77/92] feat: in-grid Assign prompts on double-click for DCA/Scene/Snip/QLab cells --- src/ui/CueListView.cpp | 99 ++++++++++++++++++++++++++++++++++++++++++ src/ui/CueListView.h | 1 + 2 files changed, 100 insertions(+) diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index 453b768..a645063 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -17,9 +17,11 @@ #include "theme/Theme.h" #include +#include #include #include #include +#include #include #include #include @@ -122,6 +124,9 @@ void CueListView::setupUi() { connect(m_tableView, &QTableView::customContextMenuRequested, this, &CueListView::showContextMenu); + // double-click a recall cell opens a focused assign prompt + connect(m_tableView, &QTableView::doubleClicked, this, &CueListView::onCellDoubleClicked); + // connections connect(m_tableView->selectionModel(), &QItemSelectionModel::selectionChanged, this, &CueListView::onSelectionChanged); @@ -600,6 +605,100 @@ void CueListView::beginRenameSelected() { } } +void CueListView::onCellDoubleClicked(const QModelIndex& proxyIndex) { + if (!proxyIndex.isValid()) + return; + const QModelIndex src = m_proxyModel->mapToSource(proxyIndex); + const int row = src.row(); + const int col = src.column(); + CueList* list = m_app->show()->cueList(); + if (row < 0 || row >= list->count()) + return; + Cue cue = list->at(row); + + auto commit = [&]() { + list->updateCue(row, cue); + emit cueSelected(row); + }; + auto parseInts = [](const QString& text) { + QList out; + for (const QString& part : text.split(QRegularExpression("[,\\s]+"), Qt::SkipEmptyParts)) { + bool ok = false; + const int v = part.toInt(&ok); + if (ok) + out.append(v); + } + return out; + }; + + // per-DCA assignment cell: set the DCA's label/mute override + const int dca = m_model->dcaOfColumn(col); + if (dca > 0 && m_model->dcaSubColumn(col) == 0) { + const DCAOverride cur = cue.dcaOverride(dca); + bool ok = false; + const QString label = QInputDialog::getText( + this, tr("Assign DCA %1").arg(dca), tr("Label (blank to clear):"), QLineEdit::Normal, + cur.label.value_or(QString()), &ok); + if (!ok) + return; + DCAOverride ov; + if (!label.isEmpty()) + ov.label = label; + cue.setDCAOverride(dca, ov); + commit(); + return; + } + + switch (col) { + case CueTableModel::ColScene: { + bool ok = false; + QStringList cur; + for (int s : cue.scenes()) + cur << QString::number(s); + const QString text = QInputDialog::getText(this, tr("Assign Scenes"), + tr("Scene numbers (comma separated):"), + QLineEdit::Normal, cur.join(", "), &ok); + if (ok) { + cue.setScenes(parseInts(text)); + commit(); + } + return; + } + case CueTableModel::ColSnip: { + bool ok = false; + QStringList cur; + for (int s : cue.snippets()) + cur << QString::number(s); + const QString text = QInputDialog::getText(this, tr("Assign Snippets"), + tr("Snippet indices (comma separated):"), + QLineEdit::Normal, cur.join(", "), &ok); + if (ok) { + cue.setSnippets(parseInts(text)); + commit(); + } + return; + } + case CueTableModel::ColExternal: { + bool ok = false; + const QString text = + QInputDialog::getText(this, tr("Assign External Cue"), tr("Playback cue id:"), + QLineEdit::Normal, cue.qLabCue(), &ok); + if (ok) { + cue.setQLabCue(text.trimmed()); + commit(); + } + return; + } + default: + // editable text columns edit in place; other cells open the full editor + if (col != CueTableModel::ColNumber && col != CueTableModel::ColName && + col != CueTableModel::ColType && col != CueTableModel::ColGroup && + col != CueTableModel::ColTags && col != CueTableModel::ColNotes) + emit cueDoubleClicked(row); + return; + } +} + void CueListView::onSelectionChanged() { emit cueSelected(selectedCueIndex()); } void CueListView::onCueReordered(int fromIndex, int toIndex) { diff --git a/src/ui/CueListView.h b/src/ui/CueListView.h index 31fed32..5a7d3e5 100644 --- a/src/ui/CueListView.h +++ b/src/ui/CueListView.h @@ -70,6 +70,7 @@ class CueListView : public QWidget { void onFiltersChanged(); void onTabNavigationRequested(const QModelIndex& fromIndex, bool forward); void showContextMenu(const QPoint& pos); + void onCellDoubleClicked(const QModelIndex& proxyIndex); public slots: void beginRenameSelected(); // enter inline edit on the selected cue's name From 19364c57e7c3ff699db2bcae5afe71fa657c00bb Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 21:45:50 -0400 Subject: [PATCH 78/92] style: remove em-dashes from user-facing strings --- src/ui/AllocateSpareDialog.cpp | 2 +- src/ui/ChannelStripPanel.cpp | 2 +- src/ui/DCAMappingPanel.cpp | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ui/AllocateSpareDialog.cpp b/src/ui/AllocateSpareDialog.cpp index 4078f45..6d711ac 100644 --- a/src/ui/AllocateSpareDialog.cpp +++ b/src/ui/AllocateSpareDialog.cpp @@ -142,7 +142,7 @@ void AllocateSpareDialog::updateStatus() { stateText = tr("backup live"); break; } - text = tr("Spare channel %1 covers channel %2 — %3.") + text = tr("Spare channel %1 covers channel %2 (%3).") .arg(m_spare->spareChannel()) .arg(m_spare->allocatedChannel()) .arg(stateText); diff --git a/src/ui/ChannelStripPanel.cpp b/src/ui/ChannelStripPanel.cpp index e5b649c..27dc103 100644 --- a/src/ui/ChannelStripPanel.cpp +++ b/src/ui/ChannelStripPanel.cpp @@ -22,7 +22,7 @@ ChannelStripPanel::ChannelStripPanel(Application* app, QWidget* parent) m_grid->setContentsMargins(Theme::SpacingS, Theme::SpacingS, Theme::SpacingS, Theme::SpacingS); m_grid->setSpacing(Theme::SpacingXS); - m_emptyHint = new QLabel(tr("No actor channels assigned — set them up in Actor Setup."), this); + m_emptyHint = new QLabel(tr("No actor channels assigned. Set them up in Actor Setup."), this); m_emptyHint->setStyleSheet(QString("color: %1;").arg(Theme::Colors::TextTertiary)); m_grid->addWidget(m_emptyHint, 0, 0); diff --git a/src/ui/DCAMappingPanel.cpp b/src/ui/DCAMappingPanel.cpp index 6e9bed3..1d66034 100644 --- a/src/ui/DCAMappingPanel.cpp +++ b/src/ui/DCAMappingPanel.cpp @@ -470,7 +470,7 @@ void DCAMappingPanel::updateComboItemStates() { if (dca > 0) { label->setText(tr("%1 [%2]").arg(busDisplayName(bus)).arg(dca)); label->setStyleSheet(assignedStyle); - label->setToolTip(tr("Assigned to DCA %1 — double-click to rename").arg(dca)); + label->setToolTip(tr("Assigned to DCA %1; double-click to rename").arg(dca)); } else { label->setText(busDisplayName(bus)); label->setStyleSheet(""); From a42046b4373dd5acb577402ed35820fbe3f7e975 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 21:55:25 -0400 Subject: [PATCH 79/92] feat: CPack installers in CI releases (deb/rpm/NSIS/dmg) + in-app update checker --- .github/workflows/build.yml | 52 +++++++++++++-------------- CMakeLists.txt | 14 ++++++++ src/app/UpdateChecker.cpp | 70 +++++++++++++++++++++++++++++++++++++ src/app/UpdateChecker.h | 33 +++++++++++++++++ src/ui/MainWindow.cpp | 35 +++++++++++++++++++ src/ui/MainWindow.h | 2 ++ 6 files changed, 180 insertions(+), 26 deletions(-) create mode 100644 src/app/UpdateChecker.cpp create mode 100644 src/app/UpdateChecker.h diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 40d296d..faa7f6a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,7 +16,7 @@ jobs: - name: Install non-Qt dependencies run: | sudo apt-get update - sudo apt-get install -y libxkbcommon-dev liblo-dev libasound2-dev build-essential cmake + sudo apt-get install -y libxkbcommon-dev liblo-dev libasound2-dev build-essential cmake rpm file - name: Install Qt uses: jurplel/install-qt-action@v4 @@ -42,11 +42,17 @@ jobs: env: QT_QPA_PLATFORM: offscreen - - name: Upload artifact + - name: Package (deb/rpm/tarball) + run: cd build && cpack + + - name: Upload installers uses: actions/upload-artifact@v4 with: name: OpenMix-linux - path: build/OpenMix + path: | + build/*.deb + build/*.rpm + build/*.tar.gz build-windows: runs-on: windows-latest @@ -71,6 +77,9 @@ jobs: - name: Install liblo via vcpkg run: vcpkg install liblo:x64-windows + - name: Install NSIS + run: choco install nsis -y + - name: Set up MSVC uses: ilammy/msvc-dev-cmd@v1 with: @@ -94,17 +103,17 @@ jobs: env: QT_QPA_PLATFORM: offscreen - - name: Deploy Qt + - name: Package (NSIS installer) run: | - mkdir build\deploy - copy build\OpenMix.exe build\deploy\ - C:\Qt\6.11.0\msvc2022_64\bin\windeployqt --release build\deploy\OpenMix.exe + $env:PATH = "C:\Qt\6.11.0\msvc2022_64\bin;C:\Program Files (x86)\NSIS;$env:PATH" + cd build + cpack -G NSIS - - name: Upload artifact + - name: Upload installer uses: actions/upload-artifact@v4 with: name: OpenMix-windows - path: build/deploy/ + path: build/*.exe build-macos: runs-on: macos-latest @@ -134,19 +143,16 @@ jobs: env: QT_QPA_PLATFORM: offscreen - - name: Deploy, sign, & package + - name: Package (DMG) run: | - /opt/homebrew/opt/qt@6/bin/macdeployqt build/OpenMix.app - xattr -cr build/OpenMix.app - codesign --force --deep -s - build/OpenMix.app - cd build - ditto -c -k --sequesterRsrc --keepParent OpenMix.app OpenMix.zip + export PATH="/opt/homebrew/opt/qt@6/bin:$PATH" + cd build && cpack -G DragNDrop - - name: Upload artifact + - name: Upload installer uses: actions/upload-artifact@v4 with: name: OpenMix-macos - path: build/OpenMix.zip + path: build/*.dmg release: if: startsWith(github.ref, 'refs/tags/v') @@ -158,16 +164,10 @@ jobs: - name: Download artifacts uses: actions/download-artifact@v4 - - name: Package - run: | - cd OpenMix-linux && tar czf ../OpenMix-linux.tar.gz OpenMix && cd .. - cd OpenMix-windows && zip -r ../OpenMix-windows.zip . && cd .. - mv OpenMix-macos/OpenMix.zip OpenMix-macos.zip - - name: Create Release uses: softprops/action-gh-release@v1 with: files: | - OpenMix-linux.tar.gz - OpenMix-windows.zip - OpenMix-macos.zip + OpenMix-linux/* + OpenMix-windows/* + OpenMix-macos/* diff --git a/CMakeLists.txt b/CMakeLists.txt index 9bcf8a7..a84bcf6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -86,6 +86,7 @@ set(SOURCES src/app/ReaperClient.cpp src/app/CuePlayerClient.cpp src/app/ScsClient.cpp + src/app/UpdateChecker.cpp src/core/Cue.cpp src/core/CueList.cpp src/core/Show.cpp @@ -203,6 +204,7 @@ set(HEADERS src/app/ReaperClient.h src/app/CuePlayerClient.h src/app/ScsClient.h + src/app/UpdateChecker.h src/core/Cue.h src/core/CueList.h src/core/Show.h @@ -394,6 +396,18 @@ install(TARGETS ${PROJECT_NAME} RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} ) +# bundle the Qt runtime into the install tree so the Windows (NSIS) and macOS +# (DragNDrop) installers are self-contained. Linux relies on packaged Qt deps +# (CPACK_DEBIAN_PACKAGE_SHLIBDEPS below). +if(WIN32 OR APPLE) + qt_generate_deploy_app_script( + TARGET ${PROJECT_NAME} + OUTPUT_SCRIPT openmix_deploy_script + NO_UNSUPPORTED_PLATFORM_ERROR + ) + install(SCRIPT ${openmix_deploy_script}) +endif() + # desktop entry + icon (Linux/BSD) if(UNIX AND NOT APPLE) install(FILES packaging/openmix.desktop diff --git a/src/app/UpdateChecker.cpp b/src/app/UpdateChecker.cpp new file mode 100644 index 0000000..f20be50 --- /dev/null +++ b/src/app/UpdateChecker.cpp @@ -0,0 +1,70 @@ +#include "UpdateChecker.h" + +#include +#include +#include +#include +#include +#include + +namespace OpenMix { + +namespace { +constexpr const char* kLatestReleaseApi = + "https://api.github.com/repos/johnqherman/OpenMix/releases/latest"; +} + +UpdateChecker::UpdateChecker(QObject* parent) + : QObject(parent), m_net(new QNetworkAccessManager(this)) {} + +bool UpdateChecker::isNewer(const QString& latest, const QString& current) { + auto parse = [](QString v) { + v.remove('v'); + QList out; + for (const QString& part : v.split('.')) + out.append(part.toInt()); + return out; + }; + const QList a = parse(latest); + const QList b = parse(current); + for (int i = 0; i < qMax(a.size(), b.size()); ++i) { + const int x = i < a.size() ? a[i] : 0; + const int y = i < b.size() ? b[i] : 0; + if (x != y) + return x > y; + } + return false; +} + +void UpdateChecker::checkForUpdates() { + QNetworkRequest req{QUrl(kLatestReleaseApi)}; + req.setHeader(QNetworkRequest::UserAgentHeader, "OpenMix"); + req.setRawHeader("Accept", "application/vnd.github+json"); + + QNetworkReply* reply = m_net->get(req); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + if (reply->error() != QNetworkReply::NoError) { + emit checkFailed(reply->errorString()); + return; + } + + const QJsonObject obj = QJsonDocument::fromJson(reply->readAll()).object(); + const QString tag = obj.value("tag_name").toString(); + if (tag.isEmpty()) { + emit checkFailed(tr("No releases found.")); + return; + } + + QString url = obj.value("html_url").toString(); + if (url.isEmpty()) + url = "https://github.com/johnqherman/OpenMix/releases/latest"; + + if (isNewer(tag, QCoreApplication::applicationVersion())) + emit updateAvailable(tag, url); + else + emit upToDate(); + }); +} + +} // namespace OpenMix diff --git a/src/app/UpdateChecker.h b/src/app/UpdateChecker.h new file mode 100644 index 0000000..da7e77b --- /dev/null +++ b/src/app/UpdateChecker.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +class QNetworkAccessManager; + +namespace OpenMix { + +// Checks GitHub Releases for a newer version than the running app. Compares the +// latest release tag (vMAJOR.MINOR.PATCH) against QCoreApplication version. +class UpdateChecker : public QObject { + Q_OBJECT + + public: + explicit UpdateChecker(QObject* parent = nullptr); + + // GET the latest release from the GitHub API and emit one of the signals + void checkForUpdates(); + + // true if 'latest' is a higher version than 'current' (leading 'v' ignored) + [[nodiscard]] static bool isNewer(const QString& latest, const QString& current); + + signals: + void updateAvailable(const QString& version, const QString& url); + void upToDate(); + void checkFailed(const QString& error); + + private: + QNetworkAccessManager* m_net; +}; + +} // namespace OpenMix diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 79bf79a..0461d2d 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -25,6 +25,7 @@ #include "RemoteControlDialog.h" #include "SettingsDialog.h" #include "app/Application.h" +#include "app/UpdateChecker.h" #include "app/QLabClient.h" #include "core/AppLogger.h" #include "core/CueList.h" @@ -59,6 +60,7 @@ #include #include #include +#include #include #include #include @@ -406,6 +408,10 @@ void MainWindow::createActions() { m_featureGuideAction->setToolTip(tr("Tour of the panels and features")); connect(m_featureGuideAction, &QAction::triggered, this, &MainWindow::showFeatureGuide); + m_checkUpdatesAction = new QAction(tr("Check for &Updates..."), this); + m_checkUpdatesAction->setToolTip(tr("See if a newer release is available")); + connect(m_checkUpdatesAction, &QAction::triggered, this, &MainWindow::checkForUpdates); + m_aboutAction = new QAction(tr("&About OpenMix"), this); m_aboutAction->setToolTip(tr("About this application")); connect(m_aboutAction, &QAction::triggered, [this]() { @@ -600,6 +606,7 @@ void MainWindow::createMenus() { m_helpMenu->addSeparator(); m_helpMenu->addAction(m_quickStartAction); m_helpMenu->addAction(m_featureGuideAction); + m_helpMenu->addAction(m_checkUpdatesAction); m_helpMenu->addAction(m_aboutAction); } @@ -1481,6 +1488,34 @@ void MainWindow::showQuickStart() { dialog.exec(); } +void MainWindow::checkForUpdates() { + statusBar()->showMessage(tr("Checking for updates..."), 3000); + auto* checker = new UpdateChecker(this); + connect(checker, &UpdateChecker::updateAvailable, this, + [this, checker](const QString& version, const QString& url) { + checker->deleteLater(); + const auto choice = QMessageBox::question( + this, tr("Update Available"), + tr("OpenMix %1 is available (you have %2).\n\nOpen the download page?") + .arg(version, QCoreApplication::applicationVersion()), + QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + if (choice == QMessageBox::Yes) + QDesktopServices::openUrl(QUrl(url)); + }); + connect(checker, &UpdateChecker::upToDate, this, [this, checker]() { + checker->deleteLater(); + QMessageBox::information(this, tr("Up to Date"), + tr("You are running the latest version (%1).") + .arg(QCoreApplication::applicationVersion())); + }); + connect(checker, &UpdateChecker::checkFailed, this, [this, checker](const QString& error) { + checker->deleteLater(); + QMessageBox::warning(this, tr("Update Check Failed"), + tr("Could not check for updates:\n%1").arg(error)); + }); + checker->checkForUpdates(); +} + void MainWindow::showFeatureGuide() { const QString html = tr( "

Feature Guide

" diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 70cab98..d72586c 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -105,6 +105,7 @@ class MainWindow : public QMainWindow { void showLogViewerDialog(); void showQuickStart(); void showFeatureGuide(); + void checkForUpdates(); void showEditHistoryDialog(); void exportCuesToCsv(); void showChannelUtilizationDialog(); @@ -243,6 +244,7 @@ class MainWindow : public QMainWindow { // help actions QAction* m_quickStartAction; QAction* m_featureGuideAction; + QAction* m_checkUpdatesAction; QAction* m_aboutAction; // status bar From 03d0d0fb77c77c9849d6c83d52a01eb6bd4aa4ba Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 22:05:41 -0400 Subject: [PATCH 80/92] feat: AutoUpdater with WinSparkle/Sparkle silent updates, Linux notify fallback --- CMakeLists.txt | 30 ++++++++++++++++ src/app/AutoUpdater.cpp | 67 +++++++++++++++++++++++++++++++++++ src/app/AutoUpdater.h | 34 ++++++++++++++++++ src/app/AutoUpdaterSparkle.mm | 25 +++++++++++++ src/ui/MainWindow.cpp | 13 +++++++ src/ui/MainWindow.h | 5 +++ 6 files changed, 174 insertions(+) create mode 100644 src/app/AutoUpdater.cpp create mode 100644 src/app/AutoUpdater.h create mode 100644 src/app/AutoUpdaterSparkle.mm diff --git a/CMakeLists.txt b/CMakeLists.txt index a84bcf6..964e320 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -87,6 +87,7 @@ set(SOURCES src/app/CuePlayerClient.cpp src/app/ScsClient.cpp src/app/UpdateChecker.cpp + src/app/AutoUpdater.cpp src/core/Cue.cpp src/core/CueList.cpp src/core/Show.cpp @@ -205,6 +206,7 @@ set(HEADERS src/app/CuePlayerClient.h src/app/ScsClient.h src/app/UpdateChecker.h + src/app/AutoUpdater.h src/core/Cue.h src/core/CueList.h src/core/Show.h @@ -358,6 +360,34 @@ endif() target_link_libraries(${PROJECT_NAME} PRIVATE RtMidi) +# --- auto-update frameworks (Windows = WinSparkle, macOS = Sparkle) ---------- +# Linux has no silent-update framework; AutoUpdater falls back to the notify-and +# -link checker there, so nothing extra is linked. +if(WIN32) + FetchContent_Declare(winsparkle + URL https://github.com/vslavik/winsparkle/releases/download/v0.8.1/WinSparkle-0.8.1.zip) + FetchContent_MakeAvailable(winsparkle) + set(WINSPARKLE_ROOT ${winsparkle_SOURCE_DIR}/WinSparkle-0.8.1) + target_include_directories(${PROJECT_NAME} PRIVATE ${WINSPARKLE_ROOT}/include) + target_link_libraries(${PROJECT_NAME} PRIVATE ${WINSPARKLE_ROOT}/x64/WinSparkle.lib) + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${WINSPARKLE_ROOT}/x64/WinSparkle.dll $) + install(FILES ${WINSPARKLE_ROOT}/x64/WinSparkle.dll DESTINATION ${CMAKE_INSTALL_BINDIR}) +elseif(APPLE) + FetchContent_Declare(sparkle + URL https://github.com/sparkle-project/Sparkle/releases/download/2.6.4/Sparkle-2.6.4.tar.xz) + FetchContent_MakeAvailable(sparkle) + target_sources(${PROJECT_NAME} PRIVATE src/app/AutoUpdaterSparkle.mm) + target_compile_options(${PROJECT_NAME} PRIVATE -F${sparkle_SOURCE_DIR}) + target_link_libraries(${PROJECT_NAME} PRIVATE "-F${sparkle_SOURCE_DIR}" "-framework Sparkle") + set_target_properties(${PROJECT_NAME} PROPERTIES + BUILD_RPATH "@executable_path/../Frameworks" + INSTALL_RPATH "@executable_path/../Frameworks") + install(DIRECTORY ${sparkle_SOURCE_DIR}/Sparkle.framework + DESTINATION ${PROJECT_NAME}.app/Contents/Frameworks) +endif() + target_precompile_headers(${PROJECT_NAME} PRIVATE diff --git a/src/app/AutoUpdater.cpp b/src/app/AutoUpdater.cpp new file mode 100644 index 0000000..6157b4c --- /dev/null +++ b/src/app/AutoUpdater.cpp @@ -0,0 +1,67 @@ +#include "AutoUpdater.h" + +#include + +#if defined(Q_OS_WIN) +#include +#elif defined(Q_OS_MACOS) +// implemented in AutoUpdaterSparkle.mm +extern "C" void openmix_sparkle_init(); +extern "C" void openmix_sparkle_check_with_ui(); +extern "C" void openmix_sparkle_cleanup(); +#endif + +namespace OpenMix { + +namespace { +// Per-OS appcast feeds published by CI to GitHub Pages. Sparkle/WinSparkle poll +// these for new versions + signatures. +constexpr const char* kWindowsAppcast = + "https://johnqherman.github.io/OpenMix/appcast-windows.xml"; +} // namespace + +AutoUpdater::AutoUpdater(QObject* parent) : QObject(parent) {} + +AutoUpdater::~AutoUpdater() { +#if defined(Q_OS_WIN) + win_sparkle_cleanup(); +#elif defined(Q_OS_MACOS) + openmix_sparkle_cleanup(); +#endif +} + +bool AutoUpdater::hasSilentUpdates() { +#if defined(Q_OS_WIN) || defined(Q_OS_MACOS) + return true; +#else + return false; +#endif +} + +void AutoUpdater::initialize() { +#if defined(Q_OS_WIN) + win_sparkle_set_appcast_url(kWindowsAppcast); + const std::wstring version = QCoreApplication::applicationVersion().toStdWString(); + win_sparkle_set_app_details(L"OpenMix", L"OpenMix", version.c_str()); + win_sparkle_set_automatic_check_for_updates(1); + win_sparkle_set_update_check_interval(24 * 60 * 60); // daily + win_sparkle_init(); +#elif defined(Q_OS_MACOS) + // Sparkle reads SUFeedURL / SUPublicEDKey / SUEnableAutomaticChecks from the + // app's Info.plist; the controller starts the background updater here. + openmix_sparkle_init(); +#endif +} + +void AutoUpdater::checkForUpdatesNow() { +#if defined(Q_OS_WIN) + win_sparkle_check_update_with_ui(); +#elif defined(Q_OS_MACOS) + openmix_sparkle_check_with_ui(); +#else + // no silent updater on this platform: let the UI run the notify-and-link check + emit manualCheckRequested(); +#endif +} + +} // namespace OpenMix diff --git a/src/app/AutoUpdater.h b/src/app/AutoUpdater.h new file mode 100644 index 0000000..5420ab3 --- /dev/null +++ b/src/app/AutoUpdater.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +namespace OpenMix { + +// Cross-platform automatic updates. +// - Windows: WinSparkle (silent background check, download, install, relaunch) +// - macOS: Sparkle (same, via an Objective-C++ bridge) +// - Linux/other: falls back to a GitHub-release check that notifies and links +// to the download (no silent self-install; handled by MainWindow's checker) +// +// initialize() wires the framework and enables periodic background checks; call +// it once at startup. checkForUpdatesNow() runs a user-triggered check with UI. +class AutoUpdater : public QObject { + Q_OBJECT + + public: + explicit AutoUpdater(QObject* parent = nullptr); + ~AutoUpdater() override; + + // true if a real self-updating framework is compiled in for this platform + [[nodiscard]] static bool hasSilentUpdates(); + + void initialize(); + void checkForUpdatesNow(); + + signals: + // emitted only on platforms without a silent updater, so the UI can fall + // back to the notify-and-link checker + void manualCheckRequested(); +}; + +} // namespace OpenMix diff --git a/src/app/AutoUpdaterSparkle.mm b/src/app/AutoUpdaterSparkle.mm new file mode 100644 index 0000000..2f0a516 --- /dev/null +++ b/src/app/AutoUpdaterSparkle.mm @@ -0,0 +1,25 @@ +// macOS Sparkle bridge for AutoUpdater. Compiled only on macOS. +// Sparkle reads SUFeedURL / SUPublicEDKey / SUEnableAutomaticChecks from the +// app bundle's Info.plist; this just owns the standard updater controller. + +#import + +static SPUStandardUpdaterController* gUpdaterController = nil; + +extern "C" void openmix_sparkle_init() { + if (gUpdaterController) + return; + gUpdaterController = + [[SPUStandardUpdaterController alloc] initWithStartingUpdater:YES + updaterDelegate:nil + userDriverDelegate:nil]; +} + +extern "C" void openmix_sparkle_check_with_ui() { + if (gUpdaterController) + [gUpdaterController checkForUpdates:nil]; +} + +extern "C" void openmix_sparkle_cleanup() { + gUpdaterController = nil; +} diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 0461d2d..c003647 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -25,6 +25,7 @@ #include "RemoteControlDialog.h" #include "SettingsDialog.h" #include "app/Application.h" +#include "app/AutoUpdater.h" #include "app/UpdateChecker.h" #include "app/QLabClient.h" #include "core/AppLogger.h" @@ -95,6 +96,12 @@ MainWindow::MainWindow(Application* app, QWidget* parent) : QMainWindow(parent), loadSettings(); updateActions(); updateTitle(); + + // silent auto-updates; on Linux this falls back to the notify-and-link check + m_autoUpdater = new AutoUpdater(this); + m_autoUpdater->initialize(); + connect(m_autoUpdater, &AutoUpdater::manualCheckRequested, this, + &MainWindow::runGithubUpdateCheck); } MainWindow::~MainWindow() { saveSettings(); } @@ -1489,6 +1496,12 @@ void MainWindow::showQuickStart() { } void MainWindow::checkForUpdates() { + // hand off to the silent updater (WinSparkle/Sparkle); on Linux it signals + // back via manualCheckRequested to run the notify-and-link check below + m_autoUpdater->checkForUpdatesNow(); +} + +void MainWindow::runGithubUpdateCheck() { statusBar()->showMessage(tr("Checking for updates..."), 3000); auto* checker = new UpdateChecker(this); connect(checker, &UpdateChecker::updateAvailable, this, diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index d72586c..51bcbf5 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -26,6 +26,7 @@ class TimecodePanel; class ActiveCueInfoPanel; class PopOutWindow; class BubbleBar; +class AutoUpdater; class PlaybackGuard; struct ValidationResult; @@ -106,6 +107,7 @@ class MainWindow : public QMainWindow { void showQuickStart(); void showFeatureGuide(); void checkForUpdates(); + void runGithubUpdateCheck(); // notify-and-link fallback (no silent updater) void showEditHistoryDialog(); void exportCuesToCsv(); void showChannelUtilizationDialog(); @@ -162,6 +164,9 @@ class MainWindow : public QMainWindow { // bubble bar BubbleBar* m_bubbleBar; + // silent auto-updates (WinSparkle/Sparkle), or notify-and-link on Linux + AutoUpdater* m_autoUpdater; + // menus QMenu* m_fileMenu; QMenu* m_recentProjectsMenu; From 296e8634866abd1f236f8dff03adb2ab22119c1d Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 22:10:10 -0400 Subject: [PATCH 81/92] feat: CI appcast publishing and macOS Sparkle bundle plist --- .github/workflows/build.yml | 75 +++++++++++++++++++++++++++++++++++ CMakeLists.txt | 4 ++ docs/AUTOUPDATE.md | 79 +++++++++++++++++++++++++++++++++++++ packaging/Info.plist.in | 36 +++++++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 docs/AUTOUPDATE.md create mode 100644 packaging/Info.plist.in diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index faa7f6a..98afdf1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -171,3 +171,78 @@ jobs: OpenMix-linux/* OpenMix-windows/* OpenMix-macos/* + + # generate the Sparkle/WinSparkle appcasts that the app polls, and publish + # them to GitHub Pages. Requires a Sparkle EdDSA private key in the + # OPENMIX_SPARKLE_ED_PRIVATE_KEY secret (see docs/AUTOUPDATE.md). + appcast: + if: startsWith(github.ref, 'refs/tags/v') + needs: [release] + runs-on: macos-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Install Sparkle tools + run: brew install --cask sparkle || brew install sparkle + + - name: Download artifacts + uses: actions/download-artifact@v4 + + - name: Build appcasts + env: + SPARKLE_KEY: ${{ secrets.OPENMIX_SPARKLE_ED_PRIVATE_KEY }} + TAG: ${{ github.ref_name }} + run: | + VERSION="${TAG#v}" + BASE="https://github.com/johnqherman/OpenMix/releases/download/${TAG}" + mkdir -p site + + # macOS: sign the DMG with Sparkle's EdDSA key and emit a full appcast + SIGN=$(find /opt/homebrew /usr/local -name sign_update -type f 2>/dev/null | head -1) + DMG=$(ls OpenMix-macos/*.dmg | head -1) + if [ -n "$SPARKLE_KEY" ] && [ -n "$SIGN" ] && [ -n "$DMG" ]; then + echo "$SPARKLE_KEY" > /tmp/ed_key + SIG=$("$SIGN" -f /tmp/ed_key "$DMG") # prints sparkle:edSignature=... length=... + LEN=$(stat -f%z "$DMG") + cat > site/appcast-macos.xml < + + + + OpenMix ${VERSION} + ${VERSION} + ${VERSION} + + + + + EOF + else + echo "::warning::No Sparkle key/tool/DMG; skipping macOS appcast (updates will not verify)" + fi + + # Windows: WinSparkle RSS appcast + EXE=$(ls OpenMix-windows/*.exe | head -1) + if [ -n "$EXE" ]; then + cat > site/appcast-windows.xml < + + + + OpenMix ${VERSION} + ${VERSION} + + + + + EOF + fi + + - name: Publish to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./site + keep_files: true diff --git a/CMakeLists.txt b/CMakeLists.txt index 964e320..6d7fd9a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -381,7 +381,11 @@ elseif(APPLE) target_sources(${PROJECT_NAME} PRIVATE src/app/AutoUpdaterSparkle.mm) target_compile_options(${PROJECT_NAME} PRIVATE -F${sparkle_SOURCE_DIR}) target_link_libraries(${PROJECT_NAME} PRIVATE "-F${sparkle_SOURCE_DIR}" "-framework Sparkle") + configure_file(${CMAKE_SOURCE_DIR}/packaging/Info.plist.in + ${CMAKE_BINARY_DIR}/Info.plist @ONLY) set_target_properties(${PROJECT_NAME} PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_INFO_PLIST ${CMAKE_BINARY_DIR}/Info.plist BUILD_RPATH "@executable_path/../Frameworks" INSTALL_RPATH "@executable_path/../Frameworks") install(DIRECTORY ${sparkle_SOURCE_DIR}/Sparkle.framework diff --git a/docs/AUTOUPDATE.md b/docs/AUTOUPDATE.md new file mode 100644 index 0000000..9cc0776 --- /dev/null +++ b/docs/AUTOUPDATE.md @@ -0,0 +1,79 @@ +# Auto-update setup + +OpenMix ships silent auto-updates via Sparkle (macOS) and WinSparkle (Windows). +Linux builds fall back to a notify-and-link check against GitHub Releases. + +At runtime the app polls a signed appcast, downloads the new installer in the +background, verifies its EdDSA signature, and installs on next launch. The whole +pipeline hinges on one signing key and a place to host the appcasts. + +## One-time key setup + +1. Generate an EdDSA key pair with Sparkle's tool (ships in the Sparkle release, + `bin/generate_keys`): + + ``` + ./bin/generate_keys + ``` + + It prints a public key and stores the private key in the login keychain. To + export the private key for CI: + + ``` + ./bin/generate_keys -x sparkle_private_key.pem + ``` + +2. Put the **public** key in `packaging/Info.plist.in`, replacing the + `REPLACE_WITH_SPARKLE_ED_PUBLIC_KEY` placeholder in the `SUPublicEDKey` entry. + This is compiled into the macOS bundle and is what Sparkle checks signatures + against. + +3. Add the **private** key to the GitHub repo as an Actions secret named + `OPENMIX_SPARKLE_ED_PRIVATE_KEY`. CI uses it to sign the release DMG and emit + the `sparkle:edSignature` in the macOS appcast. Without it, the appcast job + logs a warning and macOS clients will not accept the update. + +## Appcast hosting (GitHub Pages) + +The apps poll these URLs (set in `Info.plist.in` `SUFeedURL` and in +`AutoUpdater.cpp` for WinSparkle): + +- macOS: `https://johnqherman.github.io/OpenMix/appcast-macos.xml` +- Windows: `https://johnqherman.github.io/OpenMix/appcast-windows.xml` + +The `appcast` job in `.github/workflows/build.yml` builds both files on every +`v*` tag and publishes them to the `gh-pages` branch (via +`peaceiris/actions-gh-pages`, `keep_files: true` so old entries survive). Enable +GitHub Pages for the repo, serving from the `gh-pages` branch root. + +## Release flow + +1. Bump `VERSION` / `project(... VERSION x.y.z)` in `CMakeLists.txt`. +2. Tag and push: `git tag vx.y.z && git push origin vx.y.z`. +3. CI: `build-*` jobs make installers, `release` attaches them to the GitHub + Release, `appcast` signs + publishes the appcasts to `gh-pages`. +4. Installed clients pick up the update on their next scheduled check. + +## Code signing (required for a clean silent update) + +Silent install only works without a Gatekeeper / SmartScreen prompt if the +installer is signed by a trusted authority: + +- **macOS**: Developer ID sign the `.app` and notarize the `.dmg`. Unsigned + builds will refuse to auto-install or warn the user. +- **Windows**: Authenticode sign the NSIS `.exe`. Unsigned builds trip + SmartScreen and break the silent flow. + +Sparkle's EdDSA signature protects the download's integrity; OS code signing is +what suppresses the security prompt on install. Both are needed for a truly +hands-off update. + +## Files + +- `src/app/AutoUpdater.{h,cpp}` — platform abstraction; picks WinSparkle / + Sparkle / notify-fallback at build time. +- `src/app/AutoUpdaterSparkle.mm` — macOS Sparkle bridge. +- `src/app/UpdateChecker.{h,cpp}` — Linux GitHub Releases notify-and-link check. +- `packaging/Info.plist.in` — macOS bundle plist with `SUFeedURL` / + `SUPublicEDKey`. +- `.github/workflows/build.yml` — `appcast` job that signs + publishes appcasts. diff --git a/packaging/Info.plist.in b/packaging/Info.plist.in new file mode 100644 index 0000000..a0f632c --- /dev/null +++ b/packaging/Info.plist.in @@ -0,0 +1,36 @@ + + + + + CFBundleName + OpenMix + CFBundleDisplayName + OpenMix + CFBundleIdentifier + com.openmix.OpenMix + CFBundleExecutable + OpenMix + CFBundleShortVersionString + @PROJECT_VERSION@ + CFBundleVersion + @PROJECT_VERSION@ + CFBundlePackageType + APPL + NSHighResolutionCapable + + LSMinimumSystemVersion + 11.0 + + + SUFeedURL + https://johnqherman.github.io/OpenMix/appcast-macos.xml + SUEnableAutomaticChecks + + SUAutomaticallyUpdate + + + SUPublicEDKey + REPLACE_WITH_SPARKLE_ED_PUBLIC_KEY + + From 3a983df02b97134c5c40134629174c7b73798f83 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 23:23:54 -0400 Subject: [PATCH 82/92] feat: host auto-update appcasts and installers on openmix.dev VPS --- .github/workflows/build.yml | 41 ++++++++++++++++++++++++++----------- docs/AUTOUPDATE.md | 33 ++++++++++++++++++++--------- packaging/Info.plist.in | 2 +- src/app/AutoUpdater.cpp | 2 +- src/app/UpdateChecker.cpp | 6 +++--- 5 files changed, 57 insertions(+), 27 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 98afdf1..c3a8a00 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -172,9 +172,10 @@ jobs: OpenMix-windows/* OpenMix-macos/* - # generate the Sparkle/WinSparkle appcasts that the app polls, and publish - # them to GitHub Pages. Requires a Sparkle EdDSA private key in the - # OPENMIX_SPARKLE_ED_PRIVATE_KEY secret (see docs/AUTOUPDATE.md). + # generate the Sparkle/WinSparkle appcasts + installer tree and rsync them to + # the openmix.dev VPS. Requires a Sparkle EdDSA private key in + # OPENMIX_SPARKLE_ED_PRIVATE_KEY and OPENMIX_DEPLOY_* SSH secrets + # (see docs/AUTOUPDATE.md). appcast: if: startsWith(github.ref, 'refs/tags/v') needs: [release] @@ -190,14 +191,15 @@ jobs: - name: Download artifacts uses: actions/download-artifact@v4 - - name: Build appcasts + - name: Build appcasts + installer tree env: SPARKLE_KEY: ${{ secrets.OPENMIX_SPARKLE_ED_PRIVATE_KEY }} TAG: ${{ github.ref_name }} run: | VERSION="${TAG#v}" - BASE="https://github.com/johnqherman/OpenMix/releases/download/${TAG}" - mkdir -p site + BASE="https://openmix.dev/download/${TAG}" + mkdir -p site/download/${TAG} + cp OpenMix-macos/* OpenMix-windows/* OpenMix-linux/* site/download/${TAG}/ 2>/dev/null || true # macOS: sign the DMG with Sparkle's EdDSA key and emit a full appcast SIGN=$(find /opt/homebrew /usr/local -name sign_update -type f 2>/dev/null | head -1) @@ -240,9 +242,24 @@ jobs: EOF fi - - name: Publish to GitHub Pages - uses: peaceiris/actions-gh-pages@v4 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./site - keep_files: true + # Linux notify-fallback: tiny JSON polled by UpdateChecker + cat > site/latest.json < ~/.ssh/deploy && chmod 600 ~/.ssh/deploy + ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null + # keep old release dirs (keep_files-style); appcasts + latest.json overwrite + rsync -avz -e "ssh -i ~/.ssh/deploy" site/ "${SSH_USER}@${SSH_HOST}:${DEPLOY_PATH}/" diff --git a/docs/AUTOUPDATE.md b/docs/AUTOUPDATE.md index 9cc0776..fefc0e2 100644 --- a/docs/AUTOUPDATE.md +++ b/docs/AUTOUPDATE.md @@ -33,25 +33,38 @@ pipeline hinges on one signing key and a place to host the appcasts. the `sparkle:edSignature` in the macOS appcast. Without it, the appcast job logs a warning and macOS clients will not accept the update. -## Appcast hosting (GitHub Pages) +## Appcast + installer hosting (openmix.dev VPS) -The apps poll these URLs (set in `Info.plist.in` `SUFeedURL` and in -`AutoUpdater.cpp` for WinSparkle): +Everything is served from the VPS. The apps poll: -- macOS: `https://johnqherman.github.io/OpenMix/appcast-macos.xml` -- Windows: `https://johnqherman.github.io/OpenMix/appcast-windows.xml` +- macOS: `https://openmix.dev/appcast-macos.xml` (`Info.plist.in` `SUFeedURL`) +- Windows: `https://openmix.dev/appcast-windows.xml` (`AutoUpdater.cpp`) +- Linux notify-fallback: `https://openmix.dev/latest.json` (`UpdateChecker.cpp`) -The `appcast` job in `.github/workflows/build.yml` builds both files on every -`v*` tag and publishes them to the `gh-pages` branch (via -`peaceiris/actions-gh-pages`, `keep_files: true` so old entries survive). Enable -GitHub Pages for the repo, serving from the `gh-pages` branch root. +Installers live under `https://openmix.dev/download//`. The `appcast` job in +`.github/workflows/build.yml` builds the appcasts + `latest.json`, lays out the +installer tree, and `rsync`s the whole `site/` over SSH to the VPS webroot on +every `v*` tag. Old `download//` dirs are preserved; the appcasts and +`latest.json` at the root are overwritten each release. + +### Deploy secrets (GitHub Actions) + +| Secret | Value | +|--------|-------| +| `OPENMIX_DEPLOY_SSH_KEY` | private SSH key whose public half is in the deploy user's `authorized_keys` | +| `OPENMIX_DEPLOY_HOST` | `openmix.dev` (or the VPS IP) | +| `OPENMIX_DEPLOY_USER` | deploy user (e.g. `deploy`) | +| `OPENMIX_DEPLOY_PATH` | webroot rsync target (e.g. `/var/www/openmix.dev`) | + +A GitHub Release is still created as a mirror, but the app updater points only at +openmix.dev. ## Release flow 1. Bump `VERSION` / `project(... VERSION x.y.z)` in `CMakeLists.txt`. 2. Tag and push: `git tag vx.y.z && git push origin vx.y.z`. 3. CI: `build-*` jobs make installers, `release` attaches them to the GitHub - Release, `appcast` signs + publishes the appcasts to `gh-pages`. + Release, `appcast` signs + rsyncs installers and appcasts to openmix.dev. 4. Installed clients pick up the update on their next scheduled check. ## Code signing (required for a clean silent update) diff --git a/packaging/Info.plist.in b/packaging/Info.plist.in index a0f632c..6e7c645 100644 --- a/packaging/Info.plist.in +++ b/packaging/Info.plist.in @@ -23,7 +23,7 @@ SUFeedURL - https://johnqherman.github.io/OpenMix/appcast-macos.xml + https://openmix.dev/appcast-macos.xml SUEnableAutomaticChecks SUAutomaticallyUpdate diff --git a/src/app/AutoUpdater.cpp b/src/app/AutoUpdater.cpp index 6157b4c..6d7d673 100644 --- a/src/app/AutoUpdater.cpp +++ b/src/app/AutoUpdater.cpp @@ -17,7 +17,7 @@ namespace { // Per-OS appcast feeds published by CI to GitHub Pages. Sparkle/WinSparkle poll // these for new versions + signatures. constexpr const char* kWindowsAppcast = - "https://johnqherman.github.io/OpenMix/appcast-windows.xml"; + "https://openmix.dev/appcast-windows.xml"; } // namespace AutoUpdater::AutoUpdater(QObject* parent) : QObject(parent) {} diff --git a/src/app/UpdateChecker.cpp b/src/app/UpdateChecker.cpp index f20be50..2b6143a 100644 --- a/src/app/UpdateChecker.cpp +++ b/src/app/UpdateChecker.cpp @@ -10,8 +10,8 @@ namespace OpenMix { namespace { -constexpr const char* kLatestReleaseApi = - "https://api.github.com/repos/johnqherman/OpenMix/releases/latest"; +// small JSON published by CI: {"tag_name":"vX.Y.Z","html_url":"..."} +constexpr const char* kLatestReleaseApi = "https://openmix.dev/latest.json"; } UpdateChecker::UpdateChecker(QObject* parent) @@ -58,7 +58,7 @@ void UpdateChecker::checkForUpdates() { QString url = obj.value("html_url").toString(); if (url.isEmpty()) - url = "https://github.com/johnqherman/OpenMix/releases/latest"; + url = "https://openmix.dev/download"; if (isNewer(tag, QCoreApplication::applicationVersion())) emit updateAvailable(tag, url); From 4c9fd157263f00a48a9f62e29a13dcc18af372f6 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Wed, 1 Jul 2026 23:33:47 -0400 Subject: [PATCH 83/92] feat: prune VPS download dirs to newest 5 releases --- .github/workflows/build.yml | 3 +++ docs/AUTOUPDATE.md | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c3a8a00..a35c5c8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -263,3 +263,6 @@ jobs: ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null # keep old release dirs (keep_files-style); appcasts + latest.json overwrite rsync -avz -e "ssh -i ~/.ssh/deploy" site/ "${SSH_USER}@${SSH_HOST}:${DEPLOY_PATH}/" + # prune download/ to the newest 5 release dirs (version-sorted) + ssh -i ~/.ssh/deploy "${SSH_USER}@${SSH_HOST}" \ + "cd '${DEPLOY_PATH}/download' && ls -1d v*/ 2>/dev/null | sed 's:/*\$::' | sort -V | head -n -5 | xargs -r rm -rf --" diff --git a/docs/AUTOUPDATE.md b/docs/AUTOUPDATE.md index fefc0e2..5ce9494 100644 --- a/docs/AUTOUPDATE.md +++ b/docs/AUTOUPDATE.md @@ -44,8 +44,9 @@ Everything is served from the VPS. The apps poll: Installers live under `https://openmix.dev/download//`. The `appcast` job in `.github/workflows/build.yml` builds the appcasts + `latest.json`, lays out the installer tree, and `rsync`s the whole `site/` over SSH to the VPS webroot on -every `v*` tag. Old `download//` dirs are preserved; the appcasts and -`latest.json` at the root are overwritten each release. +every `v*` tag. After upload it prunes `download/` to the newest 5 release dirs +(version-sorted); the appcasts and `latest.json` at the root are overwritten each +release. ### Deploy secrets (GitHub Actions) From 094c9a2f8943c16dffaa914cb47b196b261109bb Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Thu, 2 Jul 2026 00:06:49 -0400 Subject: [PATCH 84/92] chore: set Sparkle EdDSA public key for update verification --- packaging/Info.plist.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/Info.plist.in b/packaging/Info.plist.in index 6e7c645..4b32859 100644 --- a/packaging/Info.plist.in +++ b/packaging/Info.plist.in @@ -31,6 +31,6 @@ SUPublicEDKey - REPLACE_WITH_SPARKLE_ED_PUBLIC_KEY + Th9esMBuKy6nRfgTeOA7eeUr5Ms3fBaGtLZFI5hyH7c= From 56793826fd21b55f7aa54eb9f4bf50af3066834a Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Thu, 2 Jul 2026 00:32:29 -0400 Subject: [PATCH 85/92] fix: link liblo via platform-agnostic interface target for Windows builds --- CMakeLists.txt | 14 +++++++------- tests/CMakeLists.txt | 10 +++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6d7fd9a..93d6bd0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -45,13 +45,18 @@ FetchContent_Declare( ) FetchContent_MakeAvailable(qlementine-icons) -# find liblo for OSC +# find liblo for OSC; expose one platform-agnostic target (openmix_liblo) so app +# and tests link the same name on every OS +add_library(openmix_liblo INTERFACE) if(WIN32) find_path(LIBLO_INCLUDE_DIRS lo/lo.h REQUIRED) find_library(LIBLO_LIBRARIES NAMES lo liblo REQUIRED) + target_include_directories(openmix_liblo INTERFACE ${LIBLO_INCLUDE_DIRS}) + target_link_libraries(openmix_liblo INTERFACE ${LIBLO_LIBRARIES}) else() find_package(PkgConfig REQUIRED) pkg_check_modules(LIBLO REQUIRED IMPORTED_TARGET liblo) + target_link_libraries(openmix_liblo INTERFACE PkgConfig::LIBLO) endif() # RtMidi @@ -351,12 +356,7 @@ target_link_libraries(${PROJECT_NAME} PRIVATE ) # liblo -if(WIN32) - target_include_directories(${PROJECT_NAME} PRIVATE ${LIBLO_INCLUDE_DIRS}) - target_link_libraries(${PROJECT_NAME} PRIVATE ${LIBLO_LIBRARIES}) -else() - target_link_libraries(${PROJECT_NAME} PRIVATE PkgConfig::LIBLO) -endif() +target_link_libraries(${PROJECT_NAME} PRIVATE openmix_liblo) target_link_libraries(${PROJECT_NAME} PRIVATE RtMidi) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0f3b27b..93a2a83 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -148,7 +148,7 @@ add_executable(test_osc_remote ${CMAKE_SOURCE_DIR}/src/protocol/MixerProtocol.cpp ${CMAKE_SOURCE_DIR}/src/protocol/MixerCapabilities.cpp ) -target_link_libraries(test_osc_remote PRIVATE Qt6::Core Qt6::Test PkgConfig::LIBLO) +target_link_libraries(test_osc_remote PRIVATE Qt6::Core Qt6::Test openmix_liblo) target_include_directories(test_osc_remote PRIVATE ${CMAKE_SOURCE_DIR}/src) add_test(NAME OscRemoteTest COMMAND test_osc_remote) @@ -156,7 +156,7 @@ add_executable(test_qlab_client test_qlab_client.cpp ${CMAKE_SOURCE_DIR}/src/app/QLabClient.cpp ) -target_link_libraries(test_qlab_client PRIVATE Qt6::Core Qt6::Test PkgConfig::LIBLO) +target_link_libraries(test_qlab_client PRIVATE Qt6::Core Qt6::Test openmix_liblo) target_include_directories(test_qlab_client PRIVATE ${CMAKE_SOURCE_DIR}/src) add_test(NAME QLabClientTest COMMAND test_qlab_client) @@ -164,7 +164,7 @@ add_executable(test_reaper_client test_reaper_client.cpp ${CMAKE_SOURCE_DIR}/src/app/ReaperClient.cpp ) -target_link_libraries(test_reaper_client PRIVATE Qt6::Core Qt6::Test PkgConfig::LIBLO) +target_link_libraries(test_reaper_client PRIVATE Qt6::Core Qt6::Test openmix_liblo) target_include_directories(test_reaper_client PRIVATE ${CMAKE_SOURCE_DIR}/src) add_test(NAME ReaperClientTest COMMAND test_reaper_client) @@ -172,7 +172,7 @@ add_executable(test_cueplayer_client test_cueplayer_client.cpp ${CMAKE_SOURCE_DIR}/src/app/CuePlayerClient.cpp ) -target_link_libraries(test_cueplayer_client PRIVATE Qt6::Core Qt6::Test PkgConfig::LIBLO) +target_link_libraries(test_cueplayer_client PRIVATE Qt6::Core Qt6::Test openmix_liblo) target_include_directories(test_cueplayer_client PRIVATE ${CMAKE_SOURCE_DIR}/src) add_test(NAME CuePlayerClientTest COMMAND test_cueplayer_client) @@ -197,7 +197,7 @@ add_executable(test_scs_client test_scs_client.cpp ${CMAKE_SOURCE_DIR}/src/app/ScsClient.cpp ) -target_link_libraries(test_scs_client PRIVATE Qt6::Core Qt6::Test PkgConfig::LIBLO) +target_link_libraries(test_scs_client PRIVATE Qt6::Core Qt6::Test openmix_liblo) target_include_directories(test_scs_client PRIVATE ${CMAKE_SOURCE_DIR}/src) add_test(NAME ScsClientTest COMMAND test_scs_client) From 09ae6ef62b81eda04dece24a9a59c1836431bbae Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Thu, 2 Jul 2026 00:44:21 -0400 Subject: [PATCH 86/92] fix: resolve WinSparkle x64 lib path and cap Linux build parallelism --- .github/workflows/build.yml | 3 ++- CMakeLists.txt | 23 ++++++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a35c5c8..38d4da1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -94,7 +94,8 @@ jobs: run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=cl -DCMAKE_CXX_COMPILER=cl -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_INSTALLATION_ROOT/scripts/buildsystems/vcpkg.cmake" -DCMAKE_PREFIX_PATH="C:\Qt\6.11.0\msvc2022_64" - name: Build - run: cmake --build build --parallel + # cap parallelism: full -j OOM-kills cc1plus (heavy Qt PCH) on the runner + run: cmake --build build --parallel 2 - name: Test run: | diff --git a/CMakeLists.txt b/CMakeLists.txt index 93d6bd0..08147f1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -368,12 +368,29 @@ if(WIN32) URL https://github.com/vslavik/winsparkle/releases/download/v0.8.1/WinSparkle-0.8.1.zip) FetchContent_MakeAvailable(winsparkle) set(WINSPARKLE_ROOT ${winsparkle_SOURCE_DIR}/WinSparkle-0.8.1) + # the 0.8.1 zip nests the x64 binaries under x64/Release/; glob so the exact + # layout can't break the build, then pick the x64 variant + file(GLOB_RECURSE _ws_libs "${winsparkle_SOURCE_DIR}/*WinSparkle.lib") + file(GLOB_RECURSE _ws_dlls "${winsparkle_SOURCE_DIR}/*WinSparkle.dll") + foreach(f ${_ws_libs}) + if(f MATCHES "x64") + set(WINSPARKLE_LIB ${f}) + endif() + endforeach() + foreach(f ${_ws_dlls}) + if(f MATCHES "x64") + set(WINSPARKLE_DLL ${f}) + endif() + endforeach() + if(NOT WINSPARKLE_LIB OR NOT WINSPARKLE_DLL) + message(FATAL_ERROR "WinSparkle x64 lib/dll not found under ${winsparkle_SOURCE_DIR}") + endif() target_include_directories(${PROJECT_NAME} PRIVATE ${WINSPARKLE_ROOT}/include) - target_link_libraries(${PROJECT_NAME} PRIVATE ${WINSPARKLE_ROOT}/x64/WinSparkle.lib) + target_link_libraries(${PROJECT_NAME} PRIVATE ${WINSPARKLE_LIB}) add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different - ${WINSPARKLE_ROOT}/x64/WinSparkle.dll $) - install(FILES ${WINSPARKLE_ROOT}/x64/WinSparkle.dll DESTINATION ${CMAKE_INSTALL_BINDIR}) + ${WINSPARKLE_DLL} $) + install(FILES ${WINSPARKLE_DLL} DESTINATION ${CMAKE_INSTALL_BINDIR}) elseif(APPLE) FetchContent_Declare(sparkle URL https://github.com/sparkle-project/Sparkle/releases/download/2.6.4/Sparkle-2.6.4.tar.xz) From 35110141db5dc1eab940ca9b34d093aaf1d988bb Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Thu, 2 Jul 2026 00:57:05 -0400 Subject: [PATCH 87/92] fix: cap Linux build parallelism and glob WinSparkle header path --- .github/workflows/build.yml | 6 +++--- CMakeLists.txt | 14 ++++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 38d4da1..a87c0c3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,7 +35,8 @@ jobs: run: cmake -B build -DCMAKE_BUILD_TYPE=Release - name: Build - run: cmake --build build --config Release --parallel + # cap parallelism: full -j OOM-kills cc1plus (heavy Qt PCH) on the runner + run: cmake --build build --config Release --parallel 2 - name: Test run: ctest --test-dir build --output-on-failure @@ -94,8 +95,7 @@ jobs: run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=cl -DCMAKE_CXX_COMPILER=cl -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_INSTALLATION_ROOT/scripts/buildsystems/vcpkg.cmake" -DCMAKE_PREFIX_PATH="C:\Qt\6.11.0\msvc2022_64" - name: Build - # cap parallelism: full -j OOM-kills cc1plus (heavy Qt PCH) on the runner - run: cmake --build build --parallel 2 + run: cmake --build build --parallel - name: Test run: | diff --git a/CMakeLists.txt b/CMakeLists.txt index 08147f1..cacde12 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -367,11 +367,13 @@ if(WIN32) FetchContent_Declare(winsparkle URL https://github.com/vslavik/winsparkle/releases/download/v0.8.1/WinSparkle-0.8.1.zip) FetchContent_MakeAvailable(winsparkle) - set(WINSPARKLE_ROOT ${winsparkle_SOURCE_DIR}/WinSparkle-0.8.1) - # the 0.8.1 zip nests the x64 binaries under x64/Release/; glob so the exact - # layout can't break the build, then pick the x64 variant + # the 0.8.1 zip layout (nesting, x64/Release/ subdir) is fragile; glob for the + # header, x64 lib, and x64 dll so the exact structure can't break the build + file(GLOB_RECURSE _ws_hdrs "${winsparkle_SOURCE_DIR}/*winsparkle.h") file(GLOB_RECURSE _ws_libs "${winsparkle_SOURCE_DIR}/*WinSparkle.lib") file(GLOB_RECURSE _ws_dlls "${winsparkle_SOURCE_DIR}/*WinSparkle.dll") + list(GET _ws_hdrs 0 _ws_hdr) + get_filename_component(WINSPARKLE_INCLUDE ${_ws_hdr} DIRECTORY) foreach(f ${_ws_libs}) if(f MATCHES "x64") set(WINSPARKLE_LIB ${f}) @@ -382,10 +384,10 @@ if(WIN32) set(WINSPARKLE_DLL ${f}) endif() endforeach() - if(NOT WINSPARKLE_LIB OR NOT WINSPARKLE_DLL) - message(FATAL_ERROR "WinSparkle x64 lib/dll not found under ${winsparkle_SOURCE_DIR}") + if(NOT WINSPARKLE_INCLUDE OR NOT WINSPARKLE_LIB OR NOT WINSPARKLE_DLL) + message(FATAL_ERROR "WinSparkle header/x64 lib/dll not found under ${winsparkle_SOURCE_DIR}") endif() - target_include_directories(${PROJECT_NAME} PRIVATE ${WINSPARKLE_ROOT}/include) + target_include_directories(${PROJECT_NAME} PRIVATE ${WINSPARKLE_INCLUDE}) target_link_libraries(${PROJECT_NAME} PRIVATE ${WINSPARKLE_LIB}) add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different From 1c8c84cb103f5b1ed6af603fc641af50f9620516 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Thu, 2 Jul 2026 09:48:19 -0400 Subject: [PATCH 88/92] fix: cap macOS build parallelism to avoid runner OOM Unbounded --parallel on the 7GB arm64 macos-latest runner exhausts memory during Qt PCH compilation, starving the runner until it loses communication with the server and the build fails after ~90min. Cap to --parallel 2, matching the Linux fix. --- .github/workflows/build.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a87c0c3..fc630fb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -137,7 +137,9 @@ jobs: -DCMAKE_PREFIX_PATH=/opt/homebrew/opt/qt@6 - name: Build - run: cmake --build build --config Release --parallel + # cap parallelism: full -j exhausts the 7GB arm64 runner on Qt PCH + # (cc1plus), starving/killing the runner ("lost communication") + run: cmake --build build --config Release --parallel 2 - name: Test run: ctest --test-dir build --output-on-failure From 4aa1ae35e6e924f19db63623c81c5c78c8ad65e1 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Thu, 2 Jul 2026 09:59:17 -0400 Subject: [PATCH 89/92] fix: skip PCH for Objective-C++ Sparkle source on macOS The C++ precompiled header is built with Objective-C disabled, but AutoUpdaterSparkle.mm is compiled as Objective-C++ (ObjC enabled), causing 'Objective-C was disabled in precompiled file ... but is currently enabled'. Skip PCH for that translation unit. --- CMakeLists.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index cacde12..675849f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -398,6 +398,11 @@ elseif(APPLE) URL https://github.com/sparkle-project/Sparkle/releases/download/2.6.4/Sparkle-2.6.4.tar.xz) FetchContent_MakeAvailable(sparkle) target_sources(${PROJECT_NAME} PRIVATE src/app/AutoUpdaterSparkle.mm) + # the C++ PCH is built with Objective-C disabled; reusing it for this + # Objective-C++ source fails ("Objective-C was disabled in precompiled + # file ... but is currently enabled"), so skip the PCH for it + set_source_files_properties(src/app/AutoUpdaterSparkle.mm + PROPERTIES SKIP_PRECOMPILE_HEADERS ON) target_compile_options(${PROJECT_NAME} PRIVATE -F${sparkle_SOURCE_DIR}) target_link_libraries(${PROJECT_NAME} PRIVATE "-F${sparkle_SOURCE_DIR}" "-framework Sparkle") configure_file(${CMAKE_SOURCE_DIR}/packaging/Info.plist.in From a1a6b15a4759125f4b6568a20d090c577712f6c1 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Thu, 2 Jul 2026 10:20:15 -0400 Subject: [PATCH 90/92] fix: add /download landing page and drop duplicate appcast length attr - nginx autoindex is off, so /download/ returned 403 with no index.html; generate a landing page listing the current release's files - sign_update already emits length=... in $SIG, so the extra length="${LEN}" produced a duplicate attribute in the macOS appcast --- .github/workflows/build.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fc630fb..60bc853 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -210,7 +210,6 @@ jobs: if [ -n "$SPARKLE_KEY" ] && [ -n "$SIGN" ] && [ -n "$DMG" ]; then echo "$SPARKLE_KEY" > /tmp/ed_key SIG=$("$SIGN" -f /tmp/ed_key "$DMG") # prints sparkle:edSignature=... length=... - LEN=$(stat -f%z "$DMG") cat > site/appcast-macos.xml < @@ -219,7 +218,7 @@ jobs: OpenMix ${VERSION} ${VERSION} ${VERSION} - + @@ -250,6 +249,22 @@ jobs: {"tag_name": "${TAG}", "html_url": "https://openmix.dev/download"} EOF + # download landing page: nginx autoindex is off, so /download/ + # 403s without an index.html. list the current release's files. + { + echo '' + echo '' + echo "Download OpenMix ${VERSION}" + echo '' + echo "

Download OpenMix ${VERSION}

    " + for f in site/download/${TAG}/*; do + [ -f "$f" ] || continue + n=$(basename "$f") + echo "
  • ${n}
  • " + done + echo '
' + } > site/download/index.html + - name: Deploy to VPS env: SSH_KEY: ${{ secrets.OPENMIX_DEPLOY_SSH_KEY }} From 878bfd0af3400b0d852ab7c7c480b8f84b87e7b2 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Thu, 2 Jul 2026 15:44:33 -0400 Subject: [PATCH 91/92] fix(ui): auto-fit cue list columns instead of stretching last stretchLastSection dumped all leftover width on the last visible DCA column. Columns size to their contents, share spare space proportionally, and shrink to a 40px floor before the horizontal scrollbar takes over. Replaces manual resize + QSettings width persistence, which would fight the automatic layout. --- src/ui/CueListView.cpp | 109 +++++++++++++++++++++++++++-------------- src/ui/CueListView.h | 6 ++- 2 files changed, 75 insertions(+), 40 deletions(-) diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index a645063..786e41b 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -22,13 +22,17 @@ #include #include #include -#include #include #include #include namespace OpenMix { +namespace { +// narrowest a column may get when the viewport can't fit all natural widths +constexpr int MinChokedColumnWidth = 40; +} // namespace + CueListView::CueListView(Application* app, QWidget* parent) : QWidget(parent), m_app(app) { setupUi(); setupDelegates(); @@ -89,35 +93,33 @@ void CueListView::setupUi() { // headers m_tableView->verticalHeader()->setVisible(false); - m_tableView->horizontalHeader()->setStretchLastSection(true); m_tableView->setShowGrid(false); - // column widths (Cue wider to fit the ▶/→ standby markers) - m_tableView->setColumnWidth(CueTableModel::ColColor, 28); - m_tableView->setColumnWidth(CueTableModel::ColNumber, 78); - m_tableView->setColumnWidth(CueTableModel::ColName, 220); - m_tableView->setColumnWidth(CueTableModel::ColFx, 70); - m_tableView->setColumnWidth(CueTableModel::ColScene, 70); - m_tableView->setColumnWidth(CueTableModel::ColSnip, 70); - m_tableView->setColumnWidth(CueTableModel::ColExternal, 90); - m_tableView->setColumnWidth(CueTableModel::ColType, 90); - m_tableView->setColumnWidth(CueTableModel::ColGroup, 100); - m_tableView->setColumnWidth(CueTableModel::ColTags, 120); - m_tableView->setColumnWidth(CueTableModel::ColFade, 72); - - // per-DCA triplet columns: narrow, and hide the fx/pos sub-columns by default + // hide the per-DCA fx/pos sub-columns by default for (int c = CueTableModel::ColCount; c < m_model->columnCount(); ++c) { const int sub = m_model->dcaSubColumn(c); - m_tableView->setColumnWidth(c, sub == 0 ? 60 : 40); if (sub == 1 || sub == 2) m_tableView->setColumnHidden(c, true); } - // columns are user-resizable; remember widths across sessions - m_tableView->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive); - restoreColumnWidths(); - connect(m_tableView->horizontalHeader(), &QHeaderView::sectionResized, this, - [this](int, int, int) { saveColumnWidths(); }); + // columns auto-fit their contents; leftover space is shared, and columns only + // shrink below their natural width when the viewport can't fit them all + m_tableView->horizontalHeader()->setSectionResizeMode(QHeaderView::Fixed); + m_relayoutTimer = new QTimer(this); + m_relayoutTimer->setSingleShot(true); + m_relayoutTimer->setInterval(0); + connect(m_relayoutTimer, &QTimer::timeout, this, &CueListView::relayoutColumns); + connect(m_proxyModel, &QAbstractItemModel::modelReset, this, + &CueListView::scheduleColumnRelayout); + connect(m_proxyModel, &QAbstractItemModel::rowsInserted, this, + &CueListView::scheduleColumnRelayout); + connect(m_proxyModel, &QAbstractItemModel::rowsRemoved, this, + &CueListView::scheduleColumnRelayout); + connect(m_proxyModel, &QAbstractItemModel::layoutChanged, this, + &CueListView::scheduleColumnRelayout); + connect(m_proxyModel, &QAbstractItemModel::dataChanged, this, + &CueListView::scheduleColumnRelayout); + scheduleColumnRelayout(); // right-click context menu for quick edits m_tableView->setContextMenuPolicy(Qt::CustomContextMenu); @@ -517,12 +519,14 @@ void CueListView::setRowHeight(int pixels) { void CueListView::setColumnVisible(int column, bool visible) { m_tableView->setColumnHidden(column, !visible); + scheduleColumnRelayout(); } void CueListView::setDcaSubColumnsVisible(int sub, bool visible) { for (int c = CueTableModel::ColCount; c < m_model->columnCount(); ++c) if (m_model->dcaSubColumn(c) == sub) m_tableView->setColumnHidden(c, !visible); + scheduleColumnRelayout(); } void CueListView::setEditingLocked(bool locked) { @@ -574,23 +578,50 @@ void CueListView::showContextMenu(const QPoint& pos) { menu.exec(m_tableView->viewport()->mapToGlobal(pos)); } -void CueListView::saveColumnWidths() { - QSettings s; - s.beginGroup("CueListColumnsV2"); - for (int c = 0; c < CueTableModel::ColCount; ++c) - s.setValue(QString::number(c), m_tableView->columnWidth(c)); - s.endGroup(); +void CueListView::scheduleColumnRelayout() { + if (m_relayoutTimer) + m_relayoutTimer->start(); } -void CueListView::restoreColumnWidths() { - QSettings s; - s.beginGroup("CueListColumnsV2"); - for (int c = 0; c < CueTableModel::ColCount; ++c) { - const int w = s.value(QString::number(c), 0).toInt(); - if (w > 0) - m_tableView->setColumnWidth(c, w); +void CueListView::relayoutColumns() { + QHeaderView* header = m_tableView->horizontalHeader(); + const int columnCount = m_proxyModel->columnCount(); + const int available = m_tableView->viewport()->width(); + if (columnCount <= 0 || available <= 0) + return; + + // natural width per visible column: whatever its contents and header need + // (QTableView re-declares sizeHintForColumn protected; the base keeps it public) + const QAbstractItemView* view = m_tableView; + QList columns; + QList natural; + int naturalTotal = 0; + for (int c = 0; c < columnCount; ++c) { + if (m_tableView->isColumnHidden(c)) + continue; + const int w = qMax(view->sizeHintForColumn(c), header->sectionSizeHint(c)); + columns.append(c); + natural.append(w); + naturalTotal += w; + } + if (columns.isEmpty() || naturalTotal <= 0) + return; + + // scale everything to the viewport: leftover space is shared proportionally, + // and columns only shrink below natural width when there isn't enough room + const bool choked = naturalTotal > available; + int used = 0; + for (int i = 0; i < columns.size(); ++i) { + int w = static_cast(qint64(natural[i]) * available / naturalTotal); + if (choked) { + // keep a usable floor; past that the horizontal scrollbar takes over + w = qMax(w, qMin(natural[i], MinChokedColumnWidth)); + } else if (i == columns.size() - 1) { + w = available - used; // hand rounding leftovers to the last column + } + m_tableView->setColumnWidth(columns[i], w); + used += w; } - s.endGroup(); } void CueListView::beginRenameSelected() { @@ -739,9 +770,11 @@ void CueListView::onTabNavigationRequested(const QModelIndex& fromIndex, bool fo } bool CueListView::eventFilter(QObject* watched, QEvent* event) { - // keep the empty-state hint sized to the viewport - if (watched == m_tableView->viewport() && event->type() == QEvent::Resize) + // keep the empty-state hint sized to the viewport and refit columns to it + if (watched == m_tableView->viewport() && event->type() == QEvent::Resize) { updateEmptyHint(); + scheduleColumnRelayout(); + } if (watched == m_tableView && event->type() == QEvent::FocusOut) { QFocusEvent* focusEvent = static_cast(event); diff --git a/src/ui/CueListView.h b/src/ui/CueListView.h index 5a7d3e5..970e13b 100644 --- a/src/ui/CueListView.h +++ b/src/ui/CueListView.h @@ -8,6 +8,7 @@ #include class QLabel; +class QTimer; namespace OpenMix { @@ -78,8 +79,8 @@ class CueListView : public QWidget { private: void setupUi(); void setupDelegates(); - void saveColumnWidths(); - void restoreColumnWidths(); + void scheduleColumnRelayout(); + void relayoutColumns(); void updateEmptyHint(); void createActions(); void editNextCell(bool forward); @@ -95,6 +96,7 @@ class CueListView : public QWidget { CueTableModel* m_model; CueFilterProxyModel* m_proxyModel; CueFilterBar* m_filterBar; + QTimer* m_relayoutTimer = nullptr; // coalesces column relayout requests int m_currentCueIndex = -1; int m_standbyCueIndex = -1; From 5f0c42a88cd4615aaeb0e2861ba16c71f043e17c Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Thu, 2 Jul 2026 15:44:42 -0400 Subject: [PATCH 92/92] fix(build): app icons, liblo DLL, installer shortcuts - embed openmix.ico in the exe (RC) so Explorer, taskbar, and the WinSparkle update dialog pick it up; NSIS installer + ARP icons - bundle openmix.icns and set CFBundleIconFile on macOS - set the runtime window icon from a qrc PNG on all platforms - install vcpkg's lo.dll: the Qt deploy script only bundles Qt, so the NSIS installer shipped without liblo and the app failed to launch - add start menu + desktop shortcuts and a finish-page run option --- CMakeLists.txt | 28 ++++++++++++++++++++++++++++ packaging/Info.plist.in | 2 ++ packaging/openmix.icns | Bin 0 -> 35477 bytes packaging/openmix.ico | Bin 0 -> 372526 bytes packaging/openmix.rc | 1 + resources/icons/openmix.png | Bin 0 -> 3584 bytes resources/resources.qrc | 1 + src/main.cpp | 2 ++ 8 files changed, 34 insertions(+) create mode 100644 packaging/openmix.icns create mode 100644 packaging/openmix.ico create mode 100644 packaging/openmix.rc create mode 100644 resources/icons/openmix.png diff --git a/CMakeLists.txt b/CMakeLists.txt index 675849f..3deaa3f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,6 +53,14 @@ if(WIN32) find_library(LIBLO_LIBRARIES NAMES lo liblo REQUIRED) target_include_directories(openmix_liblo INTERFACE ${LIBLO_INCLUDE_DIRS}) target_link_libraries(openmix_liblo INTERFACE ${LIBLO_LIBRARIES}) + # the deploy script only bundles Qt; ship liblo's runtime DLL ourselves + get_filename_component(_liblo_libdir ${LIBLO_LIBRARIES} DIRECTORY) + find_file(LIBLO_DLL NAMES lo.dll liblo.dll + HINTS ${_liblo_libdir}/../bin ${LIBLO_INCLUDE_DIRS}/../bin) + if(NOT LIBLO_DLL) + message(FATAL_ERROR "liblo runtime DLL not found; the installer would ship without it") + endif() + install(FILES ${LIBLO_DLL} DESTINATION ${CMAKE_INSTALL_BINDIR}) else() find_package(PkgConfig REQUIRED) pkg_check_modules(LIBLO REQUIRED IMPORTED_TARGET liblo) @@ -439,6 +447,10 @@ if(WIN32) set_target_properties(${PROJECT_NAME} PROPERTIES WIN32_EXECUTABLE TRUE ) + # embed the app icon into the executable (needs the RC language, which + # project(LANGUAGES CXX) leaves off) + enable_language(RC) + target_sources(${PROJECT_NAME} PRIVATE packaging/openmix.rc) elseif(APPLE) set_target_properties(${PROJECT_NAME} PROPERTIES MACOSX_BUNDLE TRUE @@ -446,6 +458,10 @@ elseif(APPLE) MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION} MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION} ) + # bundle icon: ship the icns in Resources (Info.plist.in names it) + target_sources(${PROJECT_NAME} PRIVATE packaging/openmix.icns) + set_source_files_properties(packaging/openmix.icns + PROPERTIES MACOSX_PACKAGE_LOCATION Resources) endif() # install rules @@ -488,6 +504,18 @@ if(WIN32) set(CPACK_NSIS_DISPLAY_NAME "OpenMix") set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL ON) set(CPACK_NSIS_MODIFY_PATH OFF) + # installer/uninstaller + Add/Remove Programs icons + set(CPACK_NSIS_MUI_ICON "${CMAKE_SOURCE_DIR}/packaging/openmix.ico") + set(CPACK_NSIS_MUI_UNIICON "${CMAKE_SOURCE_DIR}/packaging/openmix.ico") + set(CPACK_NSIS_INSTALLED_ICON_NAME "bin\\\\OpenMix.exe") + # start menu entry + desktop shortcut (removed again on uninstall) + set(CPACK_PACKAGE_EXECUTABLES "OpenMix" "OpenMix") + set(CPACK_NSIS_CREATE_ICONS_EXTRA + "CreateShortCut '$DESKTOP\\\\OpenMix.lnk' '$INSTDIR\\\\bin\\\\OpenMix.exe'") + set(CPACK_NSIS_DELETE_ICONS_EXTRA + "Delete '$DESKTOP\\\\OpenMix.lnk'") + # offer to launch the app from the installer's finish page + set(CPACK_NSIS_MUI_FINISHPAGE_RUN "bin\\\\OpenMix.exe") elseif(APPLE) set(CPACK_GENERATOR "DragNDrop") else() diff --git a/packaging/Info.plist.in b/packaging/Info.plist.in index 4b32859..c69d955 100644 --- a/packaging/Info.plist.in +++ b/packaging/Info.plist.in @@ -16,6 +16,8 @@ @PROJECT_VERSION@ CFBundlePackageType APPL + CFBundleIconFile + openmix NSHighResolutionCapable LSMinimumSystemVersion diff --git a/packaging/openmix.icns b/packaging/openmix.icns new file mode 100644 index 0000000000000000000000000000000000000000..217087509d21c5f2da0963b5504145a540bbb4ba GIT binary patch literal 35477 zcmeFZXH=9;mo8jQR1pvZs6;^o1wj-+GAcnt0g;@8WXYKZx(!H^6%~+-2#Dk?u~9@M zHi+aLo1Am%Q_b_v%ro=OIrE*hX1-rXm)KoZcZFR$@9W0O)Xo`z!1q?B4uSw6Tmn>- zA@AQx zDgKZ?QHw%~oIGkHE%JkRiy`wgZx*l2Tqe0q-qI=UZN%w4ie5cuBb)TZ=YD}~{kI)z z6PsSWrRpF2NokCqZ+beY>!ucuSG=Eo!-x@M^@=}{ib+;GLZ75uTE_X_{FU6KU;(!+ z{6z)5PMw}(&X0bxyl0kmrMtjac{SnZ#veiR*;b#E$k`>fdESDr&ZDCx<%~PH64Tjt z0+aZh%MS;1PW$OvOltP;EU#P>l6v&4dc!bXSJYwIbn*Ej?sk##-7VhqBtI>8t-g$7 z3pq-8FE$fXqMvKwu9uW9j;0NkiW!Bc$VEm`d`;|FqwzjLb^YwZ%?LAX#aCayo+Q7n zex|@}EsnBuO-^SvTh*FbYpYHsaK>hMi$T`Jnc8P!{3s+JP>@xU$-ZOs9Mb$I03_`H z3C-EyFt$UQJ9~Mp!7u+za~zT8T7L7I5{jg^PxutLAN>(TWqn$luJF<6%s;A2l-%?h z2S=!=mT9%fsCZ)bNEqoZRH}=bCVfnF^JU^zU@QHB{>Xt0u&SWCXzq-Wy!F*HEat;N zoQT^I*Y4uVJ2H$-;*{0TS=6GoUt-*t>lAptf2LF>=_b8K!FJ3CbMj4K-lde_vTWo7 z>j@Bgf&^pP^IA1^hKH84=J^MU7k{*PCnd^Ufb%7^$7^ftBArZ)3>_CxNpj)t-Zoj2 zo12l53F3y0*a^8G1yX}U4tdysy@#n{@@(>{=f6pC$&oodDPz{O%Dno#~TF9M3e$ zeyni>kd03r_nK8d=v)o{z3->#9k+0@J**I~DNtJ2kz0q*OMB}fE;8j2c|_{E^|}v! zbiWlbA|Jt$Q_-;h+>2jA&sqWfv~QA4;LT@SV?HA))EAzY2OK8}N7MIQ-g2_-V?yt^=l~tFw8rP#bQf;d3h8xKK6=59_Oqj~n6#~PR2_85OjWx) zksjRvj$6mRRKj9E*I86GGL%vu!4#F@0 z#37_gL=N$Yorq~xK6U%-&jinM@|S$YY+r332A@xQ_99^YrQ9j|=wI>|moq5|h*J++ zlbK{_S=LZX(x<(yU7GnuDy*nR(wQ@JyF~T^x8l+F-=nmHF2B<%|9Z82Rq@h?2k|90 zR?W)?+>2>zF1fh5bv89OuOp@q4ri-b0JQ`CIgo$|zVTCT&zJCPe0&@i!d~picJ_EY z=f?{z+rD3eXk%9!)755tM-tO=S&#YIUH@rEdZgM%P~c}v`j?p-$Hy*%Dn5kCD&YIJ z?~?wdtmaxU7pjV6G~Xha`Yzv&i$1IIHsj!FH0kT0uxHY2qsv~E`+)C`BuURxx94E( zj_NJE1UuqU25TaBU{CCM+F{H+>DHO?5r^)l8uX9mx5SO#fQ0w=1TLF4J=G9aY2Ff- zd;`)i+@rbebR_)lYo_PpCoamTk3VU)tvfMY71;YT!gb7si@#?(=iUzrfnB$zI8EtK z$VAIkzdu67>`vy1;fi-;Pq7JUsTMd3Upac)>2vto<@b6D%`*+x^|z|j+3WS4JDlHNi;Mg-kcBnuwPatY8h?5 z(LOC05fPz6{crnv_98bqg6-B3G3%qx4?o_c`F6Yek>Uxam&`>kNhpowuO_|YxRgTC zoAh|8bGMoN^B3z-R}T?EB7Qfjr1+N`6-{YTKor#B$X*@v=C354NA4R3~gOU zdJn_HW_MHPL4{v^1`F;iQt7+e5zlj+58GLQ2W`DkFz3E6s_E_h6b&EAAqL|sFZE)T zFTH)7>gZ%_JW}MKcI@$m@!?n5v+i9Y@_un@>K$G{e#Gt-WE1ZE7Lwst#^M_4>nRS8 zZEr2Ola5Nd-|ku$vx%uQUvRuFcz!*#ecE6oqp@%1rO#k^>6btZrMPiISdgLU!*;o` zWsf$V3`3NnDCN{J6=X)jLI{~+zOd~nP7 zPj#&fhN9*X$M^d1C9bozaq?ML>C+UoyIXGLxXebN25igijeN1n`SmVtVvBD!-C+S)=WLQFV1$`c3%^+RURC}hx9Bmp+d-e- z)7oTpA-WO6XMk~rUqJB{(r$T~7mrSt&ZkgG+uPeKpCBvGD#}48c>v3 zJZCD$zznk18^H$9DtMxPS~3{XZ56yd2O-D{90pY<~-B7IdsIdJC&|TT~ zHq6u7JJ{P^RcMz$cY2+nW6tfAZ122sj6bu;A2a1?+LSUo^tj~KYRdABmza3+rRM7D z>J?9Z#EpRi4f~=~RpQq~M0#!r2skc9Af{$#`{ZQGE7cV|WMvr3%F2{}I(nP98TxP> zui4Ek;zDGu>s}q{AqTZPS53y;(X^kIwH-=U%O}@>@mc!JA`7$E{vkYICqw|BiUc<(){R7o*B}wK6eZRBcEG?+JpOQ8{D`}Or--g;|x4oYp((rIPRiExn=tTvg%kK73 zs@v>loxQ3w)iqY9v)|pZu)wdmrKmbuy<3ss_{eURRp+g|UiPy~ha=K?o7B4Ke6Yaa zo$AUxie&3a0lu964~B#~F}w0X#(UcauIYmDOGlq4ru(S+_PO?7W9IdE1Fmr)jFd>K zW697WM}hN6FnJL)DS)WA|J-qay29~7bfJ`WKW6A>w12#Gc5)G=l6A+igY9{SScwYC zNI8PE#zcPq7UR&?$w_G30X>L<09o{=#L(2@RJd z)T-{UFHIEHuDgnEnUs+WTJF(J6p0-+2lL|^9w@ucm?jEoLHU2(-v??m<1g(ikJUFz zJ``LoKsY3OD4NYRDId;3J<`cn7bMxzp~&DkSswOUoj4lne)lT}m#_4^)2zP7{0f=e zE1Iz>F8-vQ#UuGw3Y-z4j9Av4ojAsg9D_=)oeoQHZ||MjhvrDo#`J%p#|M^UO1gFW zxqP=ts=f7}>ybp|D82^Q3?tbPMpeYde@DnH#JeuMwYE-+{JtZY zcUXtc-p36($ftC5C)@?tMN{9Y%Ky&B+>;N#DE~`8w5StHsuig!AtTyy&a|_<9Bj_d z2EDs-LG_MIcJtfmXKd`}ADD_ue@Yc?pX`z&N#?$yYqOcE$vV=5pKub1zHyp~>{~gI z^XZcD5T-EF-1qMR?;0nlz~Gu8>&PiOTj8Hg@l&_QVjP)hCswy5*?WhJG`>gPHf{0` z5$m@dUMI;~M*y5>M#R!cqQK80E8D7RBjKwh%{s_mibXGJhe$j|c79?e5*@kFBK{O(+M5dI`>H{R+tu3$K6)D)Ch5$NjG>k%5gNgYj;U%j`7lJv_SO z=C4Yrud+t4+pg(a+ps`;@OL9}3jmrU|7=7Ma1;U@8WBCG&*uM`5uq;n7b8Nj*I)Ok zTkgVlJ9?XnUaAAKaBx&<_{=&BURS zY^LDcQhIrDKlT$4yCZi1Z18gSa;PvZ=OY%*dhA+Hci)m&dZ8fR* z#43h2SRTYO$+juhnm8tDyAKA(@i*XCv9qzrY9xUx+)a2y@>yotR4lRuNqE3iHxynN zUJs5_Q@ir<^Ar3&`EybW&-kL}8${fdekYgeEd;c+wWYl3mw!RCD#bs2L4kC=%BMQf z5#OIYxQhxM^QbZ-eT~Bt?)~oij*ynHXONa{6WOd)OgWqYqOJpT1*gJROwiXcCf`jz+bN}Pa_hN)XbN{WE_WLk6_RCkKt ztKu6cLT%UCMf zEjbtW<$u=I;q0G`StjH66v8Whw9pn-Ja=BueevSOzUxuxuf3>==R-MNW^kT);qZ&w zVY!P2_BF+`wyixq+v*CN9k!MrdgPU2B;Pz=WaG&5{Z8VZ543)3&PWP^W(TCZ`(~Jh zg+&bySxos6kBRxpw2Sjr-Ah2Qw{Y`dvWV|kwkI`P;K!vT7B^qEKCd|E5kUh+n(FtP zoBqlhh+kF6Y;V=goiv~+i6fjcSRpNbPYC)rm1>j5s5^gT*uH!FnXQF~nb|~6mjubz zm7~~e)Q_st?~=A2A7f@_<}fui4a#6aG~sZl&^XraVhZRZA7t)Ow%sJKTv{T_eF&+o z=QB<)0LT2kvtC2;=gtq@x^`^|i9HDx_es;Kk4m?{yeT@kySWth?b|oK@QY&=%aqqf zOis)0)+j!I&w;+Euwa6xZ0eq`BxtQ)mY!e)LLQ5YpJ|An%=^zBjC?;iU~6l8D)Tp0 zRQSDK2H8h0RAN#_O1E~fB2F#L_mLNYsw1}=8UP_?>&Ug+=iW9>6k6a|TwPq$stgSb zI-8n;>FKj~K8+XGj+N~1X^Q+=i0xafj673WS@}N4Ixa5mea`gsbVHZhPh!8JDQdiO z@eSPwVLRJrCBtvX&$gI{1v`&Nosk$hkNyB&)>L^teXB0++lM3QAf_Vd7UNet${uPg zZ`n!_Y{;vdI6^;u=IeWMCTwV96&o0r%5oK*K?ka@k$|&u0Mr}-MrRS=I0rx*lYwi& z0KEF|?wq%x1x3@n?$X=3i|Rwa=q9{Yt!ICA>~S$`4eSF#;*F#w}PFX62c?j38(g-=$#AT z+`zWI?~>y>4XctX6Q6bauG~9{&%|aoRzJ3t-(sezozcU|| zR7KCis38xorxv(lb<`~urF-vIU!p??Tg`T-XmnH+@XTK@di!4!^jB1)qO-MnB!fje z3yDwwc+&K{iM;4b?T3IufAm9kaPEfZ#!Hjx{mzusaUK)LRv-T~{a$){oTDhCPi@7e`EDEeI;JUdpY6=JIN`vd%55D=#Y&|-o zEFL#AE&X+|q>Auv@RsibNn@QQUK5?A#o=~(cC*}t<&P2p5& zw7v6_xS=dweoJG~2SL91`44op(l0k20oN~E_a(h>M=5OAY}S`fGXE-e9sg6g`Lm&G zcz#ne!iuUr-{19oJ_fM86(cm$N@fp^)TD>j$l9K-nD=$9L}{hPi$~WVai9htoR=2- z9}jJ3tW&eLeN&eF#LEtT4T=y(aGyI)hk6$~>Ab#420onR+q$_Li@K3UY8>)2)2b4C zF*o$!O`p!jVtdzjQ(Y>AbgIk7f@a3d9J5O23Gn28a->v_>~0I68TEfHW53@03tPM= zu9Yw7d^^k9(fp3~UGVzyTMOmS>JN*Sx;yW{Z^rCO37MZeww}l?&*CoIKuU1_dxmH@ zPl9ebTCk)PhkTf2vNER-QneLDB!u(#x$?A7R`ul{?dkC}Z~}WD!sSZGy(sVS%5%?1 zQ_1DcIUFYa3>xrxlM+oMy^`bMnH?1{@Qt<;ZJ`2uNKS07N)}%N_2-Yq6R@6=cyDmGVRNx!HPqaqgwS(> zXai6o!o8evZ`S;;UsM~^n$G$DmlM{A7${!sNerPlj` zr1iA1A!?!$oDQm8`Z!h4Txt(4BX=4k35{W-@{e4^J1!tBk1g&acgr$%Jk_{Q`%YF0 zuNE6sC$`-RmD*ZRBy^m5{*v~NRP^9iAQ-)Qz|*m~TWT$rsK>YY^3&ImWbpcxiKb>3 zR-KTTwG&fcU;lof+}*$Zd3iMSRzfyt@wfy+jw_yQZIJNxQ{F3Da_Jj!^HWn>J4I4^ z@4*j8@q~-@_!uExK#h;x{odW(J^IR=JU%LFtN0V1QHmElTnd4H;CJ&ha;fv?3fBEm zK=9kUPyCtdjWT@jU zSIdJ^LXwgP9nl&Ty+t-%LkeXxDWu@)B65F!ABCz6`7(5@BK6IiHxfZXL4?cG?lafn z=GV+m%VG_A>A7Z4qKzCJa$jGp&j^=PS%t3F{YSs$zu&AVykK=#_;}*< zcfVaR;j&eGFW2E)p~{Qhd1E!STEkoc4g6we}- zD>pVa!m>A=h9V3o^crh}7#JERR^CcTNQl*J|1{6$1fOG;@&@a6cXn9LGBd~ZHBkSl z7@J((MqbIII_V;K__Ffy-OG5rD1|ANtP;xMvZ@Qy z?T$=@5^J@tyPJ2CmS9NGb&f51oTVit2RCqtgT9l?0Qm z;`kEkvl{U3=0lmq?7rYza^wsu#2kjJTS`kyrQBP5w10<$l(8ByGEzHlt#57;W@GHD zO8YTlmJJ=d&z?Qgh1YX`=dr?~%}*bab2rCgk%LIW6BseruBAD~?5qs5wyNM$6eAZN zgpVuX^*s2$*>~*|B-{U|L04^N-a~wzlN0Iy$2d;Vi*@55wDVfLy{v{4XdB8ZZdl^@%8S&&o>5<2| z*?y0<=pxKtolI0dW}N)6H_OyU9_1JEsx{(MQgisa)mvNT2)o%Zsow0yh!n*;qTwg* z-$$9vZU`#8{atv&j^W5fA^pIy-tQ6Fba`~ePa4BhkQ+=LDi5wjJc&cPE^e-Su{?XT zD1mR>>=s=_E{NdGzpcLV_wT~taE7W`8isH1+xKLGpJ=-o$K&x9+QkC6RVsFqntK0D z8JUD>Qpx*-iy%EV+JN!%*RXSn(q zzifUXjkJyMf6dD}1yqdT*{bGKQ~D&+%DUxnu?t(o?QwipU{3<}Ht zVC}LlLb1eooE)xK+&j>f$+YPdMI=6Sk^Qkrd;B<4LUgp(=f@;$jOWkq6k$AYI;C?b zKxd)}>-H2(3DA1kZfbFHNDvaopG(T<^E^qSYDULG z049Okkr}i)=JS@r;;K>OpFe*HS9nOia8|7l)-fK63XPO#CHHlhG?(S#NR%K+)X3O( zlDRcU3oP(6)Qm#j?SKBLIg$_b(x6A55-=W%*8B|M&qwY9aD4xQmI^PeH#+BN0RIn%Wj=rodnc=w904;+dX&PHX8)@@6U(x8CK zj_r$_tghKz9bcYdXx;{)xQV$PfYx_?6a?JJ$jJItS~S$;qY?xgoSO)!4)?Z+$Ftf^ zE1-*X=H)p?hZ5%50NSkd3tSloR}Qt@5CIHOOgTHO*YhylxxdW-;1SYKS2MUFB(zav zUPa!jo~5x^uu6%Jn8vN{3FnkfM!P;lQVowc``Q`SJ0sBQ1)kQ{Gh1*r!sY#|Xq_xK zSs8B;cCW}?Jz&()4CP~;$f!D#x}bIpRnx)>I)mdO!xZ*2tT%d55p{}v5T-mrLJ)Er z!{+8@n6k36RM{Uc-ctJFr#;tiNl9UO*jo&jmjF6n{BgP(A1fmx(p-IrBq||+Knstb zYaUmUo2wH03F{>fU?gSm-mFKH-9$F>JOB>`BF%a_mzS4EG5bBNh|g+Qu$FjQU}NAO zkH_X-kq6dAy*BKlZ#wp-j6kBGXM~w%SXdZ?`~q&~NHwl+n^d~**NV8*7=Eo{E@LMt zDaodQ7wMtyPYwjfxT|eoawv##^D5zpbcC6dZbL3WUq8dYgqnOn>!AR-oB?X;@~z3^U| znn$>hvEf!(^C9_*-R+>Jo=EW>b3rUc{PrgU?-pEk*X%v0AP?%((V^52UIX-{Ul|=U z6T6#>gxMR+h%Jlev(%vVv^IQly>`eu9dHj4D1oRSAi67{p zE+L00BwVZ$Sz?>z9x0UADXk819CkHjki_F421w&Y^g(}X^a#9)m9obzi0*u|#$l)# zzuoW_Wdp5)=k@jV@r!*W_D+ue*xX!)$~Y;aVeFD14QEFlJoM8pjVZ5kM!y+-}mJdBf#FP z5x0@~`|akt&J=htlGbl65}W-626B!{(h*mE%}?9@+)$30$3b+!l_110r-6Sbq0Du* z#Y?2N{#mU$X(29OtEWas?>*%+lLT{K7Pit|sj9l7(W?{kLZR1Cg zr-Z_u1K!(VU`mDGwUVHtMc-_EI4T{nBwX?$IXQDO0I5XsRH_)&`xF(C=rt);0|2u; zyo%>!09?HH|33VU2%j@LNCA79*RJWHEsbSG64a)o9jT-c{e; zC7Ru+40ld%uQzwQ<5BrWgw`!b@k?&rLh=0@c~K&T*p9m|pBx32&l<|&zpkyj*Dl%E z>)*&(iAgyU*L!n_;jZQo({9NSE0SeTMQ{?YBf)!5Nk_`lh*}G~{IT0>Hb;)&7b)8i zxYV;!);HGwCd0IDe=)Fh`b8GY$&0lL=vEohyP7|4x(~kHYa#dv9k&me9dyePd3Na( zX#JsipgC~xtah_TL>>2vYV?b6rIZ&%i}20XizFa=nIS7fthj1fb8ZiJ0--s$QB}Lj zfnI@oeXcmP^foARb9sfrIw$ZAWp?T%K~1V7XJxw_1MZ@g&WCPI$7nNLC>%Ph7dgBuxX6 z0+b2e^i<9_COtj`iioWh%}@SC7%JyF6B9h^hcUD0?&S})rKAlUAn}Q^-LBR+btEYt z{&ziBiBH8JTaqJyd!Q+4KM%FLZ|)VaVT_LdU@+xWdNP9w=qTkM7%^vI9pAqUGmT*xf5voGb)-r81hhdk~OEVYLeaB7ds zoBk4$+*>IbPC9;NalQnH4#Cc}-W(*e|^U42*`J zcaK+%_MSak#_mOFlb7v&_en=~G3q^PbnDG6N$s$7Qsp1xgYu)T+5Y|GX-^Ig3Qo`O zx08#?IChn%u@d@6yR36bLCX&qtp0bi$^X9-CT45U0ybCu%Kg0M6-JY#@Y#>3_K7y6 z-PO)4R!nBTkgtqk{&)OD$o4IP8)xbgvg+2J=?j!@j@&AoO^Qm8C~M$&!rA#Vfe%f# zZrgv7VaP*XFL%8>xhz{)^da4~0sL`3U)%1zna}!2Mc&w3ET_aI8Hvo;#MC=9bK%lM z00gzo^p*8?cFIk*ib}FSXl^&FzEqlzUJOdgun*nuTMUz+_2oXl>YT-*5&nDUEoH08 ztgyjpUHkoxDeO}IJ#9KkT?2PyteBJQ+*g9WfUr+)$KCs(Zvi6uiP2Qx*U1)S_|P`C z_0>q0<3QiSO)gmH0MTm=gfD$kxm&)s(q?)bZtJb5Zp!1Tw^LIC$bfW@lx2CD=`16v zwuNw<&SAoKQCD2*`6TpJILp_lrAj?KS^4b~H@=)LU8ZUO1NC3xxZ-|b!M=8MP&T$% z7Xd34;J%t3KA|_o*CH9R8;9^f&y4ZK+W@@t@W+l`lH$dsX4r*h$2&jJt%nOL1orK^ zjWQoJXv`b1;`M!fGW^??58IKurVE`=A*r0SILPToXI#3^1?SH9btov+Pcm@U6op;k z+3~*4<-bd3nXkEXo`RdK|H4PmO~3Ct@Wqa*>J5ZokwMJuz1RP}AJF0BRI8J2e&?yU zn)DA3F=>(3Jbxd*u=TE#=K}!T^-ben;V#D}vt1yGF1Ay;eds$Aece5UxW^CuySb%M z_st8qn-RZ@Z2c(Kii#1yHmWuCB<0u&+aDf9J4%fHlo;XH9$)*9P#kXy^8{4PoL)|S zb?o+EnH)qSl_X2&#||C{J%bCj{X?Bidib#$kgR*T zp4KNJ>GFT>>CNqpmz!)Xy)2asDNTQ|yhXa`Mp=CcF7xo3nCrDFry()ZjbH7PyS2{F z9_%dToAUz5@!82kMtS={c`npn%0pLNZ0EBG>YFh^e!(Weziji^iLnhi;8jZRVM>K|h{rdHV&)&twMLkl!^eJIOb$%fzB&Xs6 zg`uPE@#Fe*l`o34y2>x)TWj`V8>Aff?lC1~*8O?L1)UHMq7!1bQXp5j2_=?BrGI^4 zx=yNOtLQu#o8k!5f59cYAd~KYuPB7tIC;Dc2BX4G-FWP{*W>kowfhXTSq2q!zxL+Z z)LAZo#yp`7zN54M_g)X#y>MXp=D+rOT1W9Iq^;ss^PZiUbw>WctdaxkHA4^WPnky& zuI7|Wv@n;7y7d#XqhdA!j6n0~#p+8ul2c>RVv7pCc)}gVS0z7P?aRAjf#4fpxxz!p zup?BD?vBKMv8W+;?1&j;l_15(oJU_e0Up9|Bs9x@?~EAXhD`PQl1lsKy(6H36GV^y z7Ty~lo$#rsc-EU?GX7KS!71r_GU>N`>pU$R7FK*^2SWDb;N1ftc-t`!WgWQ_7h>&h z7~Jn-k>QVMZ6lLDwXmewKkh%#UG*5Mp#MamrjxV0UTLrN7jhCN>fX2&WTbL`iz z0nrFzyFIFjxmt$<+@EYByk~UiAd|KtldkWiwc|scVb194N4%2*?=myeuyg7znc0KS zG-wftrbp4?fA@I+O7VZj$Cs{O@!QVVY|DdcLPvrO9m)yr3rUITZ(I@W+*?lnm%TJU z2#u@^40Y}QYqw8#sZg=4PI*RV6Z^zrU&wZ8 z_jbK|?c7L!c~<^>(?jOqI#!PoF+d{t3|M~bfqLZq?%=rs`FOiY>%Kd5a$y6z8_+3Q z*Ptb5J4%vRpUUM{SHoIyHJ;h0uqVTy-G868bhp{j#l$_spXBv%L@SB2Jl9wwDt z{&)HCDtT$gV!~8{k047qJ}XFG9$3a_U&;<*@cBIuPbtMTMoTatve47AnaR&?Im#xX zf7czft?Y-&qsZw%qo|x;sA}caS?}5+fom?ZYc!oE`irlAebKiiTp|2e=5X}RXL(E+ zkEyQ=`;nwiTUxJNFDECb;p&;3oSac6-e9&1J)uhXX|`JZ#dzO6=ODw3Z?pgyggV2w zHEzQs{mF~}@5BEP5!!&3|k7s;Ny;&R6U(vcz(h+oNig@_Ob(m$L z_~t?f{3BNG_2LR}lE}`+sMuJSlSVQBNLo+^fmjoZs0PxN&c1%@jORaz9OFBQ7YehPKwOj!ot+S;Tpsyw`|3u9J_~j2rY-H3A|uD z-v&i;+OEE+eBNFDsuq~$fq3Si`1ttG8W}-Ym@v#ed!)AB*KIGZ{xDM#WTstQ!qb@l z$lXhEkrH8bxXsmAn0qP7hl4k_7h#o&3Gvy*)pdT^6T0oe+f`LnZ4I!-`9NmQZru`~ zQ$0*J7Y2e#NciR~TSO6P`abPFQvs0AtGh=F5(_3geeVPB^bS%OzK*uxNfR?NY>01y~f?%<3DTCAYZ1nTT z)1qNf<&%0c0yJ!X_t{@Xw03l?YYUT+phIsCwYRU<4V?hH8}l*282;j?T0r_kae_F0 z@ekmJg^_7hO=?gG(|`${BdCOvz%!np)GiN0M!v>;t7z@*sVg?HW7NR64U?xw3u1%}s4P5N< zMvoLKl`>8Mz=W&Yr02nI2CQBuKEO)A0`vO<8uXnAGeYMX1jwCihJeHb-3HPOMbB1P z7)XLOk#D?>Rr{I#FuOY;6%rQ4KYe!}=4=yNVKT8IVQIbKJUvi}NJAo#D64c@gWq)M z??h3kz+wpwaM~xecS%9RN~q87#(u3TYwT6$k@q@T8-AFtj@x4}CkxM?5E>SSs&iTx zC>SpRO4hIdXdzQE?&F6*r^diql5Jz9<4`z6@U|U&26NXrh6}`u{3fvqB^9yzX`+gJ zDK;BUnMUWhe=LC(#8&#lyM=`pzX?glQhS8Bg)gkb$+M6CI_!yv_`1T78v#9H0d{e+4+XJB9nOH*~Q2;v}9G5Iut6!11u&aUt9*J*1l)1mQe z>+6*gjrsn6l_2(ppqM0t78L$|3zQ#abQH_{;GkP>&d3u7!<-UvW<57yiObUZhU}*WXA-e1a z3#?^5gH;kbx>|J-P(8%WmxN7*yVVd>Xg3Uj^)Og|eQd{g9N>lqH;GI_@DyzOGH}!( zomfZHjyRLRvc+&%ToW*ah1tI7%2j&>a5`}J?B@FVz-hWg``R5uVh5~qAsrl+c1S5{ z#CyWm<%6iHsVgrx=EKVCD~&L6==eCu^sDD|Nu9g;X9aLWOt8N9%6K)St^_>{mkiZ1 zI#_$KD1xDU(9MbK)i*xG$4iRCdkBD{(Zkyq_?E0(N8%FDlC4cyT7-owcNP(+ag|I2 zu@p(A@6sk4$;ZokI>BQtj#!P%d~Qp(906HZGw)SqDY$hR7WB$)H0B#Rm9hcbnQ6?H zFeG}i*-%H0EoAx25U~>m!4|%`I$7};jQwUY0Jftt%a?s2@N)=jKU8%SS--0zLE;cd zEVEw6t&VnLq`JQ-G9ZSJ!5^4D3)%Lr&A^J9T)T2Mgp4N;u>gxo zucUQDn9-pd+ZYdbs8M)(Wpo#0gzX3SvY^s@DzoPRtC8y-;NI^5-bP<&J_xsjgo-(O zALvq4JHy(NEZuS_W-AYKD+gA!*^c65exPvqbNjV%w#g5RT!A<~zmabimIJm2#c@M3 z9R3LI<*I#m3vD;UN&~6Rg@S%M#M0>Q5yA5zx~t;+gDhY353A!ouy9LxKIA9qsZ*zr zusp5@DxhZF74wWop(4zNPSNGKUcLYa9Gi3uP{^ z(#fj21WB3oJd4h;G#Rq({*14t%v{r|w|qQ4Y<+#*R@uy-U{X&}J*{*}+-q%$(=$Fy z?aF)2)08y%ynTNx1%=EZeu2Fu4CAcKBErolHD|vbg*Ykfv(kdB<=L*s+ zy0@Fo+qeNaBv|9h`$|}&LAoyvO7zVj>dr3{5E5U`{!wYs#y}v)-_Rcd*I?XD_0Q-J zScU*(f1y86%semPxBo5rV{U-@oq@Yg_x4a6zU^K#L#5)4)h0{9h$q#muk)zC@w*h* z1(+pLj**bZ)VSSMy`y!c{`Z`aKIX;1_*}f-+t;l^SIb3gUpy-e5;&3X=JQ>Ynm@Hx z$W689TR5+7^11r_t0U(sLnc3z3#VgE8by z-yp`I0T$h) zyT}a7mAV$@=EakqlCVlnPA9OEsflfdudMrtFws%!>?Xw@&^e-HygAsbdLWWM4r-{_ z;(_)HKuyoka3~epv|i_gnG&TPLk2pYz<4T{zR{blBBd>S4oifF3Gwj<&KYE19)i#D zUa9hu5J-QP<$;pYAadb4V)EB9wC7nSroD2a z%Plh(jq8GAhh-&V@!L7jiVqpu--M^;*75w`bl(Y*4 z2wa#VXsOwl?~8;~|L7V=zR-x&>b>^@i3YtJWLlk3dvf-Ksch z6Z-e4VoubLoAXkt_JZfLZ1xGZGMT7OqCBYZWx##O!z0f6`}<=j6%bNJPhGr?gEvJ*ckW%}+dc!JXUiZeDq6)v*B5v|d<`sjm;&0yQuPdt z5akCCyoD#`W9E*bp=~?^UBv_l+~W75hr{R%Bg|0m%zF5tq{rWPL~@-2Q2ZvLBMcG5E?v5$6LzfW@s`0w$D`FhLl}_}S9Lqf8VRnlI4Bd+ zyD$PPhW3Ck^6L!;*T>N-tbhoRaoCva$&vYC_SYa?uWOC@!px#BmV#F@ei&)tG~2njj? zy)|CAhyx3anYP<@(p`VK|zCtE7qn&ebaXRG#b7!7AEt9|IFdmo7!dJt)_8Zx>Utt`_a}~Cb`YSN2b5c z?h*0HNlU0Fgkg+b(&9?hMUQ~!JR?Qu>IPt_4;dd&O--8@Y|n7Whl-z1A0jn^j)g4r z1RPeNVw5J&f@y&g7~Et9{~@i07l#eTGzP{)b_MkH87tz1E?N75J2@p#-C$49VIPeX z1v8h7&){Ea`DX!<3qh8)&E~5In5nDQ(2W3Ecer2xwbq(+cPQv3AL6rcOtWs>9-Q>` z>jiO~edSz!V+5!3QTSHjuv8f2jpaf3kV@ju@$n}xL9&N>k0qj}p#QzVw{t+;i^}Um z+6#+{_KFH;n}co$*_mliQ`F5oNO9JuoODho8mG@_p5q6=&qXIAlyQfbx zT^A}W>DNDb6Gab-&XCdp(Vmgxnh81ceI@*1vTC|b{8dkT@?+RXZG*O1;KM2i#+G;q zF$u`u_>^)oA^@;2y4ig8Cn`t>dhZ~SJr(L{Uu)=}zERaDX@%yuuAaEniYIXp|4Wd6 zXGKmV=@86+;ONI}AmOBI^&wu-P4k=v_yaFna0Q^Kq{Bp4#g1C0g;o41oKhx9G!(_y= z0ko7gbZt9pL#6~coza03jMheove0KsUM+uk|JBd6Az)j|CgTi%ZzOQQ7cEf(Ugjfr zyd{WFboM|-T{&db`CJmV#{4i5qTry(OW^PK*|)dNBQdhHoca#EABmvl;5+aUw&*N* z($J8QB8@?Y5)vbG^GWkV9hGZotsUX>sr7IfF)>k57QLyuqeOARX_GHKhL=uT$J9Lg zqcS;ptSgqtnbjXNWPkWmX&*APTtUy%0l(Hb0=zS$I8E0boqA_xzA~w47$4 z@!ANRTwxTO+}e89xk6+qqiLV5{)Y$#VQ`#=#sCJVEt{Kg8bCGr9Nx(+y##=6vm$;M zg7^U154zI3ExVhsIQ|9rn<#WLI#@p7+KFO<_H48%>h&Y~ubZl<8qc_2M1b%dQ3$}X z$cM_xg#PWlSF16WMT;%^X57H_oqY#tgCl#<~IxpX{!(e02nwrT6P_r(kw z_`MYn@FG#S7Gb#@plg>x$x9DqTb!N6;Pqh}c~@JT;|TP)1{wBlQmMoGFhH9K#x$2; zyK_7*BdQMxWNE4$QE6%rNwia%GC%H8iJpPua)(#MhXi0gvQOOimSb7CUZz^n+4j+XZ-})F#jCpxqU4@$tOXxa=hIYGl^g8aNpt>;Pyvc*Yy9t1cjn zpr-|OS>LR|5(`no@jwSTR9*%j9!l)s{d5B&Ni$&n9XZK$$i6v$T~SKO-k&00D*2Qh(O}jvP^*=zEu7 z**rR-ki@9#IhJ@SD(~i+KHSKGdxbBQ(H%Z^C!ZH-2QV1cgNwugzA%#_3_05FaN}zC zBFxHt#evHc?c##3hb$r6s)KmFe+@hxtk`~znKB4=CoXlYy>Dm;JK>Kof>~CiV*zpM zpcIV^TjT~O;0W4Kg5RZ$SHxMjjp2D75WCS|C;<=gLt->!qogA~q2wC?{S{91y5)#Z1^C|%UFjAtO9QW>)F%ha) zqa^S+2*K89X=;kyiouhROmvEVFf4bQp9?@mG%x11ta=NPF^Rc7bxVQZUnat9zB;KT z_7!MovY$f4>_-@wS9Ir4^cv2d+FPIPA~<*w?pVd`Nv;2R>^Ld|w=n-}nReP~kCeZS zXQoIe3&(|2vCC54>B`dmU+sN)Je2#}|2-;oN;yK5EQg8^IW5ZGH*!*0$`&!5BxOyO zVHh*zoRUIii)<-Tc0vq8kr7$5WF6VpnJ|oX=DBY5oO7P<^ZmVE-{154J%9bqAKcvc z{n@V1wY)#q{kg7>GI@Zg^!nC2Kpu7ssjtjW2K5;{NQeKoVby_m>WK=h#};Q6t+YB< zzLZ}3^g>5GIjzz&a%LG9a#)TfksFZr zZr=vFvzD8**=?m;E8C)mO)e^mSSDEVCCv6{K~xqxrv17iX|OMl_SJ50#ENEos}HBv zX3fo{!TCGxN7*;M&1sEATP|u~G3NO6C)0W5(qGf1x4e#1=8+erYCf--;{6zU3Q4%% znqk$sO}Zz&G$8GK?u!v6iM_$ZUn2X(%>zy$fgE8zuMZZ|YzQrL%6)H<`}+8OlE4x6 zPgcRii%(4v`4!O$lD8Fn^$+>tPsgn4t+qLOv$@*$N+fDjr`3dDUyf;-Ip5@~SUZ2& ztHU;Fe_q$bJ^Qp9F_esro$MU%@IIV!%TuHBFW26q5d~bNXWR4=V>u6;hT9W1b}R@x z{COqC7ju;vZQ9*f7t;Bc+yD6*X&8L!)z6ERNqq{cKO&|C^Gi0ig!!+oXfV0z!3J)TuQrYV+OXNH0m`Dv>?Mg$$?d=-N&odr~{nHAU5* zf>8u$LL^c;151$#G}(Y5W9o&M)aWEZ%c zBu>tTr(T*56uC4qjbrzoyDXdP^QrYSW$C!m^Z~|Qf7)W#gu^0JyU_+y_J|+!!Knxx z&U=^plVsv<6>{4)_06YJ1n~-Q7E7`hrg4?ea72|8jD`NHneDVS$fKWta`(f>Z(pKX zx&*g8!3nBwT$jmy^L4X$@EKLCxDVy}xfqqhV@nvVt!msql6Ev_|~ zC`YVk*p|;2;ccH~kzH@$!@BIg8kH-NOD>e@%0%6#n_D7r)Vc!B+yuYGT+hPE$hC;! zHB-PH=g+5$srGo@3nf|`k6**2*BrM95n4-n1&}jk;sYl`M3aw@*=5C)3Dc%!IM}oW zi3?$n6nP-x*oT}qBSj&Vi<>fK@muw-tkhTwsrD>px1WMN-S*3)+**Sd-Yl6t)CTV-l7@Z|k|tSBu!3Z;MW( zCM3c%y>A#)7JwE%K7szc%+rVx#ZRgRiunhMQ2K7CC^8ga0FQwNq_YkeJPFNa%x&u2 zKU}!-^YwPiK(RH*kPZN==0)rvx08H#&>l>}N3;!xmfpIN(N1fo@UBGQnGsPh??xM> zECw)Zm?#_$q)hj7rczA_!?atXhv zz^pon=mAS6gqgY&?vkv5{xx4dpC(%PdSowhQ6FvcB?w%j)PocSIRivyFWTR+j{Rqu z8+N^ND33_g1y?NKP?@BR*(cDBwuh6j0)8#>m>+%7qBZZu2;VPZ1YS}O@PSOy;xs)` zSHfsc9jVqs8?}$Mf*vQ@_wggUcLRC#&Zk?WH9yNw8{zxb{@>=%P|w?~2*}ucdd9I) z3MLzXL)cljvCHVJ3`Ozlgn)}n5~bcJ{dZXmOquuUgyc$x2&r@3&0$~+P0@LGpMFV2 zXDHTpI{S}l+b#X;XBN^6yFih!CeT)bP(sDko%lyPO`tS&2Y|^QJXVWecuQH2?B0bo zbxt}i_+%<#ekt^hxGTTbHzs-=Jjqc+Sul8V$#b;|lrLK!u4l;L*KT$Dtg&9}?&=-T zp%nuUOMnR__0_U9_wW)kN0g9@btu10eub<&zyy~jDBz>xLO$L@71T@jZ|$U-*r;R#gIvshtv?{P*tE}eu9;;s{|eXHpjIFJh-yQde})o++^>m* zqeOG^7V}{&U-sopt?x@5cL`h!>`9bPtQDJOeT^i_#m%RZ1I5-M+hT!`TZ1*+ai_)F zRYfqZj)l)hT(7R7@^%UF5~gfi$38zrySIDQ9o6Rz0@v;$R?V+>~Fpn2@ z77x!fsvWrYWX*$}K)lPDD;n2X$^E^`EPX8L5j}qwf5gXJi-4%TdXL5k_(K6?dh4`bSGx6<=T~ zK9tks@Vk(!o1kwo2b9=txtRe5l{p!QvONcyMmoHXxw{lgm}ZCv+sc{MQb40YSTCQ& z^WvlGA})1t_$zZB(;ujP1EK5tDjHW@m&;aM=LTGt4k63JL>~+(KEZ=)p(1-XsR?kw zY9@%d%)HwC?c2BC_4I_me~?tf`nS9wSQh-fRQP+%^gmoYCRn4X=o|D=STeRRQrm!* zKhm*(D>#~-QF4?c(k}?+)rCLwn3rSbI$8BG3^Wdi*1kLkisFD0!Cq2dW7a(82wp;8 ztXmw!TO4%1b>H0twdGPF1o>cj=*@ve&&l9COQH(?u+zP&oPmOqWcziGL2tC1r3H;R z75C|KlA270{Vd|{N{XB1>G-3+iiE1cnwj(*ltSPaIp=_h$7e_oCn_2$Py?7)*x#zo5Tc)B}gvt+^ny>NmeO|RoE;Lbb>}vEL6HV|zqZY3$Bi1JY-oVQH(aT3gfLt%Msn7V4hGliQ>@ z#VM_yxGW;?ycSd3U;-K*F)R$_SSkBSE?O6oM~r0dQmoo40aPn+8G3_aLRuc$x;A=< z2h9pun=p5UeO!zzQSdCDwaJYP)-q`)1Ju613CFQ#mX4hPt9vrfG{})W zc&kN-r(3z^kOSBSvhQ!zpd=BQl5mGReTfCW&jIWPm=!Oy*$fsvO{A_`9K_K1{5HV* zZp{%a0rejQ8#^K)NaRI3Vbd#Zw8G{ck=8=1gyWIDQVYvA&Lvi8f(1I?da0BfCXP$( zs|GfYz*5vD{2spB_zpbMy0#hXZ6iN!aSlwVu^p>OxM=w{+T!Yj=%pK6xnsQ)3;?_D zcIKO(#%job0k-{y7TkMCfvhnmhrcK0zgFSE_BRvzU~HTIfp#VA4-oxy<7)eM{|ZEx z6{CLNF#Z1mqW8O?v`xzW6GR)W`f!qb{s~0u8K5m0|9235(R#JXq(4El%u$qk&yM~T zM3Yd+hRB5f7Z8mu4&cS}{|TZ)a$qe8FZ}_ct5K(Hg7KdqdiN@$$o>^Xe{275my)6E zUqN(PG3tB^)B7(VdPWlN8OpAL=(i}yM#8GwLsv0#J4;4&bux}FH20$`7JZEc^G2!f z)dYs_pYYkQyviy!`tA}|U)b4=Wp3E7`0XZGeLin=(YB}F&M%8`3`N0EJ2)e3nq-lP z&jVj{yVgEvTAklmnPp_1T>zQ~ z@Ec5otl|`&$I7eyQ~?CF4Y0g0nQ;o%2j2zZn+Anla6Gcki=f;67?XcW$-e;6D5^ya z$Nm{a8wbE`t!w`pM57CA;98MVj@;A=RUB<=X#VEXrRAHjm4L_G5GBZ9#BlcTCgp|a z5$g^R@IQ+ctqlF4IGFmPB0=id;v`h*+;J&As;{mNkU6Utl_S|NBXOGd{AtB`BT6!b zR0^BXe^(tV2lWb|^@dMX#g?l27$A@DN%8jZEio3uf^=0V4o;(~rpcBINO$nZzjvDp)+R96vCtwxu0(yO4*ARE``pvanVTq>X;2YtS zRoOorVyo1dNtS&RFB#OYCXD=>-f^P4L#UJ~-E2k%4M!zDl7RLM&-^HP0Z*Iq5bueTN>kTZfjIu%0b6sL!_aS>K0yfyfxgC~NL zSv~xB#EYd?S9lK-(ow0}<#K5`(+N6JA-u6LS{9dSmSOEWbM;=IKC@ucW!aPwCb`3SgN2Hbbnn=pgB4z7~PmGWK zpK+BD(;dEs4y;8UnPLlNQTuvZfjG>oq4OfANZBc9lB_8pr8yIKWInyVmh!>?w*J6I zHTmrr(m~tvOzd(c_%F>8bA6l|KV|)9@pSlBDox~xBGFC_&q}so#%Ttt#ZSRTgoJ5m zAooVM;e^+CZ8m-;1AL0TE#g9yFXh?ShR1E{g`5M=QBM#w0W%#8!R*>Ag1aD^=}ohX zS)MtbdPgz1t(em~;xl+CA6%2+SJojXt8#+d2iZ?Di?c_Jt{$Hq?9QHUVr*@iN)0aP?i+{~YQbA*jZZ&l?O%rh|KeIimN;EHUTL6Blf$7)=f;BZ zj?W(bRPetckg6%6C`VqQi2f{_{Vo+xT@TK@u!--UpbSz1mq5j0(Egv0cEuw6M#q`qXEow z-qWQqh*sebi17)+;H%GbxvN^&eu<6^Ufd`L-aQ#b45?Ze9JuBvmH^LX9o+-f3ZVSK zb+iXD{NZ0BE9i|jKtq&+W(84a4Oe6o&xN^&L@LM>c>Pj-W1AHo6FG%@b`sip?A|AEX6R`6mEC`%T;M@N0GD8dxLrtdR6S7dqgg+j}2b_Mk#(oxU zOEk~+KWm2i|CiMZYS{jV%RVi%=>OyHu{m?}byXQ;3hz9FXr_2MUG~@COmXX~-tIf8 zz9zj~V8ipL*HjIoUDsSYCG*1RYR&7Lhc8FJJ|4A4?e+`r*PDB{%RJy361Cp5E931C zw=w?S^FJIGw#=F6RwqO7WxAERXC5oAu3ay{D05ska-lNGoQ=WQNd*4IDCO zMiW!kdk!6$7PkO;d4O4m5Yi{)n?-M)_E1e82me1(lZYf_dJGw?-Wv*T69q<((7|Y7 zE%*OWZpRD%Xv06Mgub^$QV;gVgJN0?;v!Kz`>F^Yx)SH4>7@m9uvJ;|8FM`E23~|83=G&m{$+!N>x?#56N>!)NdvZ8v^@{;e+hOAJ&Bu8g zNgjt)JtEJ*hu#rYsG_HF4egaGSJcrW&Jzz^m>=77NCWGI3;6|o!{sa0c3TjV#&aZr zA^PNv)RkmLhAMZ|5x)_wkNPWFbdaSi$BhX0yXJjFQ{390jk=hHMX)w13zR`1uoS1k zzbX03SnyktcBJubzsR!c~tCEj&S373B9_n=-Z>t@Y{DEPI4G8W&92&a5#U!Hmf z_qA&4#qKJh2`mVwF=b^b&X~`{N!4+vt%LI(r)}q)zgpWRsI7XSHa_GIoeYTrc^5-j=WE!|T|MK&y3%842Oab< zTAr`$ifBxtHNR9n{^W(_MC`pYLTcIcUz#s|8OVmY5L9yQw*xmRBC%;BGHo28w8)6qvPoHZm#HD_7?qkFPTcpRJJhrOMhe36OUR9Qgxe)RJGE>@W=YO z+$XF^HTun+vZ+sse4j6^Swc5YyE)bxGIs69_d#YlcI@9Acn~d50p!Q*BR{3-k;V=c z4{y(2ic$=g)>tWYeM?W))r*U052?-(p(Zne*V8FKy>KS)xsvQ>j@t!BF`NtW`}lDn z_FiT{0ktw+_0llql+Ad?y}nykLhtz%eC-{5-_5;mYj~0V@HWp=mGIc#-viS7U~?~% zpG6aqrfUjQdBp>OuXZLIsF^!xz~|qsxk5)m?76&0f)x)9?Lc>VMo;v<^+yE=5%bTh z{jLY#a#;&x9YMAmqQC#5BH+KjAN`>)|E9*@yz&39I7A5`u|>yG1>x}OoFFpe;Ik#c ztf<@BM)68$?pukg)4~on4hye6BW81^j$7?r3>UKaOY*0#b*k{Mf5Hcwv=8kUe>~`Y z|Iks9uNyWZBhoW7wN_c51|0Wqh*qnNj@it8ER<>-TC%x{n=_!tPs)@F(lX2EMTWS1-7P+r9))prouUv}x0(#)Bp=WJR((?-Q#cr+Aq+ zcoCw*9gUw=RYlHxsyMwrAo@sa^y446jrge+{0A^0rR&S?&JcMUcEK)oqX^rBI^jvx zYtz*G#iwnT*Qj4lltqG>eZQ0~syip24DtzoFGadY5av8k>=z%oxs=M)%eLP6gSjST z+Hk6PPVStn^yk$u$MIAJPVp}M%)V(YfS4j$@h|uE@Tz^)>Q?H5AfX@`NeSsn&4HTv z^`@i0%nBgu5a!cw4Ndz5+x`Jk=Bvb0nL+;hqlDe} zvQ_(#hWb~c^St63#YcX^Dz+3tSYUICG@n-R_X-{eBKtQGe@NLgFz%|V<*_A{cy-IS zfyrGb+81>Dr!H32rZeBEMFKl(AOu-Igv3>i!x63A?W^?h+BpcB zt@9P?wscI+8Ea4Y72}W2B)qV|+y$LniTCM$!z6HZ($26L$=*^slgZya_K!`z$U zE+^=I8+c6rVxo-o^*QP-mJD+7x(Po%6kiqkZARR7rm-7USet;iFJ^_=de@h)(>fmk zi)926*4mp?dI9s`tc4@sErj&;hG$)=IeO;SWTq;*L$SdpR)1J)cD()0Z^@qI6+>;F z%(sQ;oA{k_ftPeWSe*7VNKL?-k{A2oZdu2h$&WFdFUg28Ypro9>UQD@X2h+C^9muh z=QWmL-wUF}Iki{a{`)R!o(FEji}3J?vWwq51)c)R4S#aj3GY=}nrnbDZ+0$V+zUu< z-img9B*|aL=mIl95V7m(`g9Jp=^llHU5b9i$QwUGE?U~*rzB>cTd67fciF`vw$dgN z1O@v^OAfWFqaQFDPD$e?KD&Oh*@oa~1l*wo&O;aU-Nm{Bm(rEFg_&+H!)yTS(C>y7 z4j@}v_eeW7DdDg5ShR}c39J7iH0iN%~Eg{6Bj35+$)E` z?qLpD;UU~4#p&&}nz>p^v#3)>30ym8uGvg%=7Ol8fmA+M$J*6Vl(;+A$EnjU+`wG# z4^oZ`4j9MpKu2eO&i1_D)W;Y<2sDXOZeI_#kj1J6=TYKEE{P=f+34NqO0e~vP^1+i zpraIVcCjbA0xgK2Y_(S?oq%NG5rfHiF|^=FVOW!NJ&RFqiD*|)eMHcE=9~5=XmFcCK)X6o=+GZ-%o9 zA!>M~cE|-_+<9g8XjE~Jgc`Jy3X#nLFZr_H5cl|;T4YyoK-+%yPXR< zyb{RKuslMLkkoS#gPi*&<|df^hYoq@YWw;gqM{G#Ac#IhK*!at*#wanOK8EL)0R)0 zI|5J2o~e}lT7J8rLN>h!5f&GRjOQA~9sOvbkg+uoDzWs8juOc>Z{tOZ5EY$tvBR`c zcRy)-g7msFG!zr&nb~KK)>Yo4s;*8jIGBW(s~M)w4h0-(Pwj`1X$|Qog|U4yx3uPR zU4+;znVM;<5*3u@m+7$(FxU@=waU7=xd8`^T8qKr6ZzTwYE8sGm+?W&9lUo|sI)D(u?&KEsp!Wg15za5y~g$k%8ABwRmv zMx=#8a`5m)OYKiC>ZTJdQvF%({AF&Untsb8U%y`WSQg4?ISS7&8a@x}Ie7Hw!a|-3 zJ31YLemzWFT?o;{E$=JATVG!Z5GwM2{fqRTHyUs>#|z*7EeYG0nN{Vlj=*HJ%R?i zFz+tu$7>Kid?~+U*S4tbP_(}6JcN@|$=!5^wtjTn4Z79^Vxb)K`lV?&@Q=|0=W4v? zoBBAbA`K4@nw*a?)7RH$)A6w`R;EY{dZYtT=IyN7>f;7=m>G_ry1|=_b!FgF7Yhui zkxI;s8%F1Wd(@NMjrH!mxjs03_+;Bkqc1=V)LwQzKD*dCPH=_>u96BM3f=E|i9u0@ z98wn@6XWZq-DOBEgwZD`>`iikd>_M3c`>T?IX*0UJbFgPZpqu@)ld`}znb4-Z>6H4 z;b+!-vD*c6WE^6FbPHT)8r%6w&CS5SikK_fo@b9I%4FP&ZZ<`XA;!cRg6Dfg_##`Q zPz~IA_Iv~*E+%H##ps#E9CDf!SeAin`j#-yTb~>|{;vIn8#Ut?9i0BWJ;R z8TGmqv{Y5pd@;+23W0s)jXwsxS=YPs8iW;sdZ(n(CE!`s#(4R_zQGnY@%v+2z0qlV z|KE!I;bGZELh`54WxdcnoHG|NSy$UKtfWMgor`~xj5Ecd`pf*bKY)sQb@!15_k*WD zLCl4U@BCNef=7b<@;Dh$i-uemJ`mV0E!ijD5{v4vy4?8gREUYq2;_Ef#~4CTV4E8Vq^xoR;mb%Ab(wIKyP<3w5)N_Yr!3N2<4;sjickdAaFrEi zyTB_gEnO7zv zL;PzsB)G62rpDLKwt{n*gy|gQusSE_Qt8Drb@rc1C(dKO+Cb0aEx}~&=pAh*sz8N{ zpGu%jduTJ&WBs+Qa;R(Uj25STW&ku3uX z(*~fUIIt;Br{Yr`ivZO%X9NVaZPRfz={^=+y^%BneJk++*~A<7-VX4JD?!c5j^rn2 z@eEiy*n%x6Xc2w!Aq_E4g!6NU!)+HJfy-ql4hn6}1H+nFl6^EFWLIyXx(2yh5j`MI z*^}PoeNDS~ujEj~q6WN&ryJ{ymImLyf5Cpb)ZG+G(;|2K>FJF+wPZY!^M)^Vh}>K< ztQ^_^-SED-UGoev|GmVAMak-%WdEwI=5PoNcIdYB_mQ#pFnTSwdd!sGB60a6PZaOqs~_1z{~+o=2D6BS8zaXaAE zvg+8t08So61`^c{A2wZSE_GKjqi!ULi9og$%ZNdj6BQ1UmriAd0BqXdtDhX!6Mq(8 z14%x#yMN`r%XKm6HE8=6VLj#%gQ=@urOlfvIQ{9SEw6l{ch93z9+-}ci{9i zoo^KmBUV6gebZG4=Al5tizKyf6uq%evo zU;NcP3-R2S*xbFaW`T~q>g0O0!o@zRtyYS*s+CU<2rvMJfo9hzFDi#`DI|>+=Yn`X zKE;p#Y#^%j0WS%A6~H@!d_>14TD$7^Z9YZ&XGfT#d z2yMX33t>%%bd0*pa1y4vnEhUdCqVbufr`5a1a#G2-b}0mR@oZH=jwK-o#9DtL%~S- z2|s@w%>Ldu=5y)IE*-B&kMd33IGsL zG|_chR}=~d9-RSKNP66!uLtb(ZrcB@l~*VM4EA};iHdzKmo+6R6iC=1vbwq~I-cz( zfK>3u&sR*sikF=V%=IWoo~R7lNlQoF#oIGXs~mEkB#2w99+gBLiS6M zzmeiPBxIreGb%^mNm$lCA0ZTOTd0`yT2WF#Wu~LQ2|oEcvBnHeRH(NY%+}--ODDN! z;XyC2fW2=qSp32#fTUFc+RKMymKmzKdcE)5{JK4cVEgjEmbf*0lg8Qrif3wT%SnP% z9gY-7NIbQ1g&Yc~j+Diq9C-tsiVp!Pe(E>sQWsyYtPJ~=KI|vgRkg Q2L@r=98sP0M=G@c0Ng)#K>z>% literal 0 HcmV?d00001 diff --git a/packaging/openmix.ico b/packaging/openmix.ico new file mode 100644 index 0000000000000000000000000000000000000000..b8d5b8c09ca427075492cffb8a5aca0862798cde GIT binary patch literal 372526 zcmeHQd$3+rb^nr}5&jS>SQ75dy*Ce%5T-Pb5|St+Sd>Bn36Dr1&ge)5DHRo4A5itq zv;^c;UJ(J?Y1PrrFr_@y89-}_I-T)>f>;$u3zuvzUTgind#`(459mbiJqPuAAL{jbbLLF#Uwv?| zw+H$rCZ_iP1;$| z`Aa7b>b?5!XY`iBK8S*zseT~69_Y#b&7C`U{KzAZd=T2p(2|l3$36gU4BA5_=j6$L z5av$noj-qmf{o;H`!H@ljYYa>(V~8%K5n1J5#`2b`7{sdhLwwxeRpi{H{8a)?D*_{ zdj7GCCz9JPc}u??yJX4-+3{~&c}#*WJukhT9iQGmW%bhJxzD`4-*7uUkG9j}u`j*9 zWWiYSvF#`J8*bB_G)<4k{;a)OetIr$r}=sIruo^q+4*_)R+*obm$yH#p-%qd+^-Jg zUfc%Fo?PT>J4v>9-PZlm*m|)B5t=gX_8)Y7S^wIs6 zm0z_#JD>Ke$}x>6d0A>2U$s9g7x%Yr_@m^83s3Gh+)j^iA^+Rgo;|%Dde%+)m3eZC8>1@$=Rt zU;5Y?{f65nnd$y2@^PMNd|GCDJ8wK5_cm`>)osgvO%4>w!wMw-=Ynm_w=@P zlRoqV*ztAR_dve}I*su*omY~bb@?FYC6bj#k3II-1X~^t`|9#da;d+rd~QDJF;Q-Q zUA{>#_1Bfp%_lu3%FVCKH|c#FoZmlv`}x!79k{)9{qQ+I&TUHn!xco*q61p zDgE!;a@+y>FWr1xYx+k=Mv_z49G$#l(;NE@x3RA*{=E4kBh`;-w*Ez5QQU~WH^+Zj zJlTC){blnft1r86tN+}b`oWsY%IEfN^^;s~K8-U`Zhl?9NiX%A+(sDiPn|R%{S?#er|qUKIt*hy7IaC z1HC@azyAk5gW$F1%4@TuIrw>Vvm;rRF#z^WgU^6hw`~J{=1qSFMn4Q<@!JM%`R(BI z=INw+SFbl~5-$<7{D(Ssfo{A`fj4a432pj1#Uj`#{+kIt+6L`jXg`PcJ7|^T?|2T{ zfVSKN?OZT_W*&qijyPgI@V^Vqx4oSH@1U(|46wfd?EXauY$t5Lm@{Y2!~g^4%=bEE zJ7M?^*pIfK*FoC}!?|GleI2&{-@x|g%Gy6a^V|wIuj%oi|5<3I*U00KKR&_6*-iYW z$5*i(ZCbNtO}~+S9Bq0$r$5VY+McTNv-WZORpmE5p0hX0Z`z)!^0W4F`&H#PJ)W~S z%WvAAs`9h;ar;%}H$9%S7y0MC`Pk%%Telr>-4gq@0k24g1pDv)$Y}@IPS;+MC*A)0 z|7MfXe&k8F|E>?6YPA2Z51%UD{?pbjOLqO;mgL*loSi&!!@2#2+t`QqAEf&~ZC?}h zI5s+xod2hPoc!e($0Z+s=PCV$+t`P^wEnu=P1t|k1?!FZdCj}li?_ed`cO3{4#c$#*~p?=Xg{`zR9*S{bl6WS@TlYfs`5>Cn)<8CZ+iT|&aU6T_-~WHv;9rq52`93?8E!oU&`9qp?!Q#;QS8R zoBcetZEz3xkq%<`F4~Uw;0MnS{IWwBeqne9q6=~O9{L;op7V##6Pyq2erR}KyY$)$ zW3##GY9DPtTktu7^T6hz=V$gJh}pf~E~K;IeHTbue`*PphW$5*@W8LTkbQ(S;+g6C{=b`-=n(>)O;JL~t*K*KiwEgpd-qOxxfdw##|F1?yTQG2|gHFtI#Ka``QSwlP|7L=K(xpdR9c($p`&f zz;ywzhgk1~Gnc}exciu6j(IiEw?!EHzEAMD0NB3}G5*g1_Mh6~9~&D>uyu7l*lm4o zgSgkOUE6OqKJYz%5VOMXa~H*R!GZaT(|Z){!t zzUSv*s?z7%{<`|BV9gs_SHJK1d6=s7`L@5V{wi4W#@5yEdww3KDt*4~udBZb*1WNG z_4}Tmhp9@RZ~N=&uY#4v;(HW-aKl#Pb9LXpers}0&U;sAewx;`zwZ3BEoHZhu+y{a z=z}uy>e^p-ei<9HV+bQhv-9%y>)Ky;ex6NSABUUxoE+rWwZHECGB#$%JahYYW9<`m z9Fv`ww_n%(y7TjF;`%;+*;`rspS$doVDV3kjV2eL{wMusl0UZngyfp{oYrr+jn5_{ zZ+zZpu=vyYQ8)i==A&=lvuRaw)jLi|uD#%-NniRzKb@lD((L?7MpDw`_p=C z=6~)3eEoeE#-#N$wQXa6+7_GnW!7JO_gz{~Q`2J$$@gf%=P&e6_FeaBoi4X+>`&|WJwI)W z&9-m*>*}|`oSsuxzwh~JEH>M|?XRof26K8&UH!i2r?J><+t}~&{M|;s4NlV>8~rZN z-);1pu-Nq5=y!SkZlm7@r)iFjemXzps7YtK-$s9We%);w{Ty!Mm$A)8e;K)D$Jpq1 zdH!yrzbv*g<81W1Jb$;*Uj|p%F?IF(o?jNHX`FBS>*_b*D%)RIzwh~Fahk^Yw!f}^ z6RxuTb@lt6UlylnoNxQ<>Nnvk+h13|@A+kMn#TFIzpnliF01c$`F?)6uKodj#QsFe z7nSavpbgl%Iv?!*3A8WT+8@9?{C*?8>)JYoU7icyf7y!P0Rjw9bs2}H?RyYAeh&`5 z|8};ej4tQRmh^W30snOo`2fEIn*Ey*AGY0ZVzPP`O@bFWnFDye1b=6b<|+-t7hfu~4nW<1T4Nt>{A&`eE07W?jD}9e-F8MuD%_ zZo{vAS{L@GuMQpN9C)ggjW2>DT&?Bp2|$ zA)NPJuJMod*!N+G{oNY>IO7kskI!yA?j!zi{+TRUvZUW|yKNg{*FW$#J-5@U|IzoW zSFfIa&p2+knFF!wPt_V;?`!>YKh{0HW1cAXrR-{SJ9hmkKjX6^jruxI{<3Au4v0VY z<&ABkFLwP+__T`5*!}Mtf0bA@G1fQ!*!4FN(<(A!_rGuaRbth|Sl{?#*WW};tH_Mq z|Gx28iB%J0edCW^e-kmSA~SaX`^H}-R!xlcjX!q%O~kZ{%-H?!8-JBpH8IvV{@C?5 z5z{I%WB0#r{8eJr#8}_>W7pqAOsmL@-T%JvSBX^$x{dvP zW%`XAqB!TD-~ZNR`O^8$p9}Quf8YA+#I)?(wZ|?>ergeSia*gG-+Zp~9PsUb-}=kO zu+F$IeDIXYHBT|mQd|e>*pi*=+yB1xXZ_K{{*ybauld=y)BY1X&(rvOcuv1gUQp~@ z(K@zd=lZTc-}v!o(lPss_|8O0_q{pV;xBq?XxA9rkIc0tm zzUKiD z_`e888~RPLwCyj#(=}cj{x9-L8~SbIY?@bur)#`6{9ojgHuRg~Xxm?er)#`6{9ojg zHuT%Z*)*>RPuF;D_`k>}ZRj_}(YC(`PuF;D_`k>}ZRmG;{_ptycad+};I-lZA{=e# zmwf-b$Tw~9+VFo7jyCj5zW-h1n>KiD_`e88Tl%Hm|1R=P8@#ssZ^9D0ev@C?>W|(3 zzVWveXOkY^_+!^^ic4GlvHRaQ{G6#}cKxQfwAJ61|D`^E-xmJ1;#pNs z8~7zZf8PfFsxfJEd>i;BKY!l_{x;)TRaYDMoj!jP*!$@5iHsWvloSkW&OCPj> z-=)2-Wwn9d6i3^Bmp*6%ze{^v%W4C^ZJbT>T>795{4VWvEvpUura0R6yYxXD_+8rT zT2>qQZR2d3=h6pl;CE@SYgujJcY6Ns`1$)b@VkzgOL=YJm;C&F8~9zu%eAaF@JoLF zz771YW9Cv`Tll3uf8Q2^V`H~!f5yNsoc ztl0hU8^2A=T+Z>0KX&~tV`(ERcK`dvZxb_@bA02EUBAm%+Q^FC|Gx3t#LVR!-}qzK z?=qG)vSRnYZ~U20gVE8^ zWcBLR)A7e`>}$)8*!8FU{5)(wp4x9U23UvK+KfG6MV&G0ANb)hpm&o`lpfhy{!8%n z+_`h_f_AwCThs<90Nun{;gjJ(46y@a~SKfspwDrha9+FANS$2;C79F@cDmkUhJt4;(w>+K$tlI^?e(~*+f^ZTJ zAh;ZO=$K=Ud9^9->3)d)A({i>#2@PZ%NWyijJa)G2VjmK3)($5bAB!ivHvv2l8d*A z4>@o?wASwtgHGqZh)m|M0Pi+aT&wiYnKS1NgX;r&jYxi8zKrL25c}NEcva$_9Sbt& zLBngs&qCAsl;s1whjSIw>1Z}?4eZYXJ!>J)E(iLrp!Y}6ehuyaplMz->jU&L`ucJ7 z{U8QwAjUIo;+*VH&l*pXz3KhgNivz05*L$mvPhxxO2 z4e!s&?oST$XJq$>*}A75P!FgF)C1}PHy)TA7IVPyXV)XC{5;&eLMSEsIgR_Sjy$Rfdzk{ZTkpFP5b2zlOK)VFmEzll>_5(Qo z*$ou0P3Y&+X3shGoW6PgVh+DB$mt)z_5;u`*D>c$hW^8Zlw;#&OiWC?2Jmi#&o(yz z_U}XcJv7Zh^+C)zfHm+4)Wr4h-0o>uBaIhg%L)M7D!_OXG`w~lbF3A+#-Gc97vUKB zS*Vkfp-k}%zLS587A=|$b$=1ycuH$t*F4Pus~mU|YUskU&-X}JDBv9CV6gf95O2Mg zsK+|)Sf~7QIkFFG>V48b8^C$XCaC*8y4G8*^;!$%Y9U%bL4EDU>t_;<^JyDkZ5oG~ zzgO#C>t1u9l^no#yUa`1ps#JPV>2{-ZYkPzS+Q&EJ>Le!GZ+~4h5O`-384r^?~Mq{2X{3uhG@5dz_EY@j~6}wLbYV*SgdgrE}n6 z7xx9A_VIf=X}P+s{?HugKn}ojc{kKK2Y_n{_&l%P-|s-Ib&b|zR89`O0&BpTHfsRL z8iV$m9GSZI>w2I$(48E>XLBpx7lb+bJb!m$to5j|D$0SotDFPCd!bL&SfVlsI#ymQE&2SeYBnj4xMngdbf zz>8S(v<5)T&(OF>5qJGuAMWS+O0k9dKaJ`iuKV4n^{@4>IS^G2Tu1c}aequ>A64ut z9V;|9g3OIau>Rp$|2IH<^*g?LUZ`tATsiQ2IPX6U?ggBp>wA#vyVi4D^=uRGlcDx6 z)%w@^*Bpp42QGoQ@6^~w8GD=Kh0Y1-IokRpZ865PyIM1 zRDUS`xZ|Ib*ZXt&g3W)bagK)S6Am}=6~EpO3NZ&Lzd0HrcErTtCcfg=`VTP&D8D%x zB6h8RUH?MO>$14Gx+i{Fd0iVv_&C}%TxIR$a1+0*JRR4q^CXHdM?;M(hnx6{zgu}7 zYP?+IqWtElYrRtBakz;u#a`ET5I&B&)~oVltfySui63gb2p>lkzl?khH4eUTad}UC zU-mVvm+*14X*+z$=Wr9>mwl?Y*>#uV%h6Edz~Lso;%_z&L-nt8T$JA&m5#&KJ`OkW zZRJa!NBB4@9f#Uyw?1=mCw{2@C43xJ{C4>js^3Dv#pONmL&2NHLHIbDofpbJhnx7J z;8h&M>o~=iqoMkZ!%cj}Kb$wA`Xe@6l;0c;)h`@w;wyfw{}6M4@|&X}Vn<9IZsIF` zt^W{nfbyH8A!671*Yz*dye^B2t9#;?mDja#gpZ?L!&TN^4ma`3%F}V(I!~hbax~Pq za=3}F_`8+Yp~lNKF3N9?y4EX29*3LwQtWkY2jSzWYrQH@#(K)do%o@~i|}z&@yp29 zP~+eW7nk?M_hnzxdI=v#o3_K3d=5A9ec7jan_YJ)z8noT4jgXcEBP~*ZL1J2PnTe8X|VBe_j7V&FiwbxVk5PS$SO>NBB6}HC$!w(qr-*!cm4);T$JA&4b?9kZsIF` zt^cm%zzNG2C)ZuDK6&`cEy+`#Ixl(dGjDISp$^n_?Yq_|Z(4p-(iK0UomAf(?GmOc zwsN?MUqz;l^|Mxv%^OK>eD{WA-yPcw37>m#sZ1`*=-IvX3dhIVu@L9osnE#IK`6=ej%A=lSxRJI)ka|9D>@&t@rol;0ec zVy|mEINZc{tykr_TN9q#xlL^SKXL0et$(=BC^g@yzBww@ezogJv9(X1K_thB%X{KW z#!$yL!pG4%I&^Nc>p#_*R4O_CT;3C3GKM;~5k8LA(V=s-{-x#sm-ob%YJXk32p>o5 z>e2bltpQSx`(&5o*mHSLe90K<*hcs`T1SV@ZLapww^V0RspR-`c~5-F80y$Y_&8ce zhtAddmzo1y-VpN-N9Q-U21q^blU#8{y+<9UVHix!OnH zQk_YqlHNggpZ?jbm-jXY9D>u#yftfma9^HHd%7~xx6R7WDIp|BYYgKqeJI5TmSm~ zAAA3Dc~AUMbC~dPG*tW{;KKK8R6Q=YH6GtXDA~tc-VyqzPi1^} z;)_7BW}6qByr~e_%2fE zapm%!_|kFM+DG^}YAavo)vN2W{u`e+n%sBACNXQky;p2Z@SUV(?e{f~%X{Mcvae~q zgpZ?5+oAI7uM@Zi+zjUdK0gcKIX}LOvcaOB;=4$@oFI1;)m*Q!pBj??AUZTK84+l*@bKSC!k9@q~|~((&l;KWpvV%g*k+eqtdbH z?>}q(+vF&h_r$l6-Gw=XkE4>Y=lxq zZRJa!NBB4@8Hc|AMeE--Ke@aozO8)e^9UbDCF9WFf7bf9%}*}xiEk@k`aHtNQOP(= z?_ZJ9FWGmo+s5TR@k8|!;p3=e{8H-NWQSzm#cmsy_rwp?PlS)7lJT4BPD;OI-^Fel zm-oaE)lYl>f{gQnbyKP+F6F*cx5k8Je z#xJGLO?F83UF^1Tc~AUM{Y3aUDjC12?xgff_Fe3@ad}VtQ2j*sI4T*xlsY%rA=!7a z+s5TR@k8|!;p3=e{HD5-(l6O}vD?PwJ@G^J6XD~iWc*U<++>Gj-^Felm-oaE)lY)$p%xx6R7t$gY82p>l!2 zPcH9?Z!2H=Ji^CO$vE`)pSAvN^OMVa;@irXK9BHmR5}j*{byYRY;u&#d*a*3?!p|x z$5H86^!J~&{%vxU%X{M6$nL@%!pG4rVA0=y)-|ANo^p9l{Hk)hGM?~pv@3YfPW}C7 zT?fkME0_1gFDtKW;|L!|yN0W*y&P`hmzAgEy0!LDd^s9wTshptSNz?|>rmt68W-g^ zM_uccB9FsOd@1(2wuA6-)U{rfCu2S3;!gZf<3;#5s`zE(Yp8MXg^SC3;`_3%X}yGx zqfOi4OFoC2_`d8@z0Iz>6km>p8V3$H@fClwc^ImHrQ@Rf=BRWWw)SzjiEk@k`aHtN zQRz6;KD+gqi#zc{^)KP$sN%QFuTcFK3N9}1i608yEDplQ(d@iX_Bq_d4+XE{7+%LI zz8nqJZyavoEB@iU3DqC5;iCNJXsCYSa1&qgYyF3q1C-w!4G}wH;&2mR@oW8um;;pG z91Rh>*1xWQq2_g2TwL7~zpT8jjU#*(+S^#h0U@#+AcOe8u0b zybd*9u5nR*bJVq7De^en#Ft{PYdZ)ZM_ubxc{0{hF7Ct+HC}{|ql#ZfzJ?kHU%0ru zC%!NHn$}DBING!wzT|VbiSNrk)!Xd4OY!AssBz$M6JPN+n}?zLS2`}rZ;ndGVQU|U zoA|c!rOzXL9F>kk?Xz2-xwsQQRR0n_jw*h;{0h}?q2S{3p7^2Q&Eg<@9L>%PWuL=M z{7~>Jj^TBj;>*!4`R#<|i<9dvSf4z6<(8z4-^N89sO#Eytxw*x{HUZ$zT)Ce{4QZq zTZe1B$R8A6juy#~!W)}6lHB<24avScwkM|FqHnq%b>r`Gj*X6VVjbY(PJAgbRy)eq zb{St$d^uW1md`O*`}bbH(OcbT*OhxddRiye050yt_ZeH&?Y8FA{-pSFG(AV+_Kg>A z5L5fK7T|e+L|>(C;^I#H^c>x8t{&U=Eyb6kZQE5v--#<0dp+l;de2h)EFk@Q02g=S zSBZy??aul@@#U!Gm|hRp`m(hrWgo8zO7wWG zZT4#m#h0U!eQc^bDgBat$Hkrak};@lTK`gNJx|7l;IX}6& zC%$A1YMa)-lv>ZTLvnsneREVY2DMG=KdYn(L zF{o{E*MA%D__19}@Y!U^`APN7QOOw8wz%tGzyA|={I&k$jz7Lx^gMhIq2&DJ zj{C%yj6rRSyZ%pHaa8i`o&MhWXIew>{U7))Qpx#A_03Vq7}U17>mTEP%S9)NI|smb zkxI`^uI`C19f#Uyw+2vrIV#=1xm|W(~Oaij4`rlT^CjxVRHvIu5lj?)u00 z;~H=?oCo;)EP&_y_%6~e)IR!*>YJllUv~8+-EUmni7y>T+CIDDv@vj!MU&_SvmZT-=Ets(%R|M-{(aeue6{P;ha1PyA5u zW^oWcj%MeDvd`frekgbq$M8B%@#Sc!e&cWxU-1v;O{o5e4HxA%M?>`shnx6{U+X`_ z9H9K>Xo%Pm6Nj7lieKwL#2ldf=4go6wf=Se3pKCH;^OL__+{mFZ5-j_XxDI+wU@(9 z{Ic?NT({1XD83vGHLe_P;w%1c<#nj>a*d1fo1?DvN|DFmCcYGVUE4wUIO@-@^r_`=2IJ@I|n*R)>3$I+(k@FkzaO?+SWsorMSU5YP9LyZH6 zoA`>q**px@ztVA0esfeh4qN*;+{Cw)FMS^2p>NgHI@fH7Y-h}Fp*l$zwulTk8L(BonZ;pnDUF%=hzfkkKEH19@iC!AP~*zsCcfhDR$hl1FW0yzzd7n!uM~M4ZsJR^*R>sl zkE5>jsyrF%DHnI*hZ--!$5F*EBVR*}gD+fM-V@)KeNF2nd>n1s4qx&)+{E`~pXzOP z-KF?)G}Jh7xQVa$o6W;e{VN?80^Ly$9YcheXM=_gWA7I>tE|%b0Df5xNfTY`}MzBV;@!Q^_U;$F@HMl z@Z8Tvs{hf^(SxD>U(_`~*8t6dD01K>ta++`q!9Oi)YwN6`{`qV#yi@0-v2A! z<9uE%!?FLOyxPaUMT-{AhB-gao2PxcE@%#PJ_nwgn3#Btss6DaV*J+{=g!AjkMofq z=ksb9j{Wa1s{KLz9|Cjt=FQhWT^BS5I-3J~viJNbtf-=1E2fHi)!7(gJ*)y z(e*!w^JQMJr8vjnl-|4YL zbD&c>@GY#}qS`LYgUW1`m*(1W*o>H0lE5IK)gAn z>q$3a|E%rxK5iX=`dI2OIc{%Vj)aDYdj_dH}&6@`*^nHQ42I?_DSPtOl z^Lce@yX@S-^#HvlC~y7OV{)7^`KIg7``lVExG$jZ4UIG3be$0rcQ_W`?&>-}SMzy% zIOo9c3jA8wxsc|kko?rVXzX*+OHli7%d5$@`v%Vn->3WvVfqS9W#@eoP zP9NBGI<%kZI-u)-=0GDkun&&;=WuoClMivn_XqqHwC6Mj8p(n5aX`1@I4^h(>i#mU zEuXbrMK``@2<*l83FBF02!n4x5wdGFnrO8-7p~&BB=JySl zH1lWRd^5X;1{RptLj(60%^xo`zt;l*dyD1+fL%rWSxGWk#0P*yYy`;gjlrLq_lZLH zfP*-Gq5FEh@q*~11&4C{y;F9Y{?F$4yG%2u`e)VUPZrCc(KNqUy>it9>H+nDdO$s( z9#9Xc2h;=V0rh}-Ks}%yP!FgF)C1}P^?-UnJ)jL*^zTm>0O;S}E-(AUpHhZwIp1HsO|5dpw+yDRo literal 0 HcmV?d00001 diff --git a/packaging/openmix.rc b/packaging/openmix.rc new file mode 100644 index 0000000..1e5e6fd --- /dev/null +++ b/packaging/openmix.rc @@ -0,0 +1 @@ +IDI_ICON1 ICON "openmix.ico" diff --git a/resources/icons/openmix.png b/resources/icons/openmix.png new file mode 100644 index 0000000000000000000000000000000000000000..14c304c508c77c1e55519343b761d5e66d9af663 GIT binary patch literal 3584 zcmcIndoR9H#-OTo8MDHQK~?)27d00&CMLfC_l(~ zDeA-gAZ;il(!=28sLnm6&Wk{7p*&CcV2nRQ*V@kES-F+|Gx)CS4ks;U9)%Tz4Quhl z)Ute6-F`zOLD?$b z3y$onS7)y0;3cqK7r4dvJ~(zQ*xmiB%*A^F-*HpyRc9z(c`?kQoP9YF91KrNNBbDc^_1xUMSUxA)G1Z z`f?xUDQP#r!s;3Uo0}r-QVZ#BOt_1-J(8AdsS3m-6^8VTjQD@zJ!-atrU5+~F}GtX zDRAlFM7pq*nlzP0;1}Yhf~H7fR`wO=trzL;FHe1DfstS`bTut4O-6Nnz?<&~pllKr zw)4ZhBUHi|_Jp5;XU0lizjYJH9dnXyJeoAw7cSK_K8`p5$J(n#Av(yAZeLaAy4m{= zA1FbdlfL=1H8{5MZI@Ja<89=eVhcA{B<;5DT0buI6lfuhA4Q{{y=Jeje+Y(v9f-UvA-$6DM5cC7aSXocPPjLajtp zdE^xiw+Pt7DEwV63y|70enQ@KVL*|*BV+4$(9mj3pwCS&uc?YbL*ds8+eq3_Yhtkp zql|Vb_*a+Iji=;+?oB51x#VWgdqj3)qUD#-z`(#=rM(iF*bBolrq*E+21W?W zQ(Tf>XqVzelnS^KqGp2uKv&T}WATh^YR6Qy4@D_FEX;=J>f$oc+4*44o^me!TkX5a zy7e`Cy-#yFUzmi9-2?(5t->FN!=+Ww>Gbx&8}B#gT|Lm>2+W5&C-|%7Q41w^$LQqk3d#?G# zb9eICGMjWCSEzIhQ?e5@X$ynB=K&yZ1&F;uV7oHFxNQO24*-b!cTY0>q(BXQIKp_< zk!d^Dx^wCl+kdvTf9J~3;WWJ|$t@d%&}^w^O9%#k%dzVh}#3Q**zq>aY&?tA{|?(HI<8yVM*tvtqsb@@d=*-S{=Lj>vyRJr#Wk=H|fC zaCcyBHyiJiNLGtxd~SD&bki?)awSgeiYwKi1h^g1*dCe}+C_0g*9y*=9mTso^$4LH z7Z;~nJvuaWH8KCJU9kAS(UCgEA6%OKudYI_p03XdLVDuH$~3fm-SjUdM8A4<$?T~(ItwjxM zXyB}1NaV2xp5XhDv zZ;F!k+0MW1E6$Y^CzK%4@lk`j9*K?zwn4$?GJiJAB(EcBm< zUo&KLRk3mBO<4mGV72f6iQ@k6YEOfX;|Jl`hQF!%Ncvge?e%tHA=J6H?Z{sQKeYq% z>Otn?$Hg7G*M0_ziaZ59TIUvoo7Zfr+i}@ z_|?*kiW0;I$);Islw{Nvn)-NNO^2Y5{wX};&8>;w^9F!iT2l&2d zhq-RUaB>B)QY_?K}~BF4Ys7}L`$+!~{`H1Ia$^b?gfTDGnRfYNe!Eki>?U*o(*ax*hmYVkCA zBMo4|N)+UQE#5^UMsnV8|B*~^qWKYCt8}SD2eLzu2I7!XENi#@RgIhtWQYQ(Wu59~@x_>U zW8uSx4-FF%68P%$h?(Pp%`bXOo042L_8gx51%n6=iz!1XP*5uc4b z@%tYvHjR=jFus_KXiWM%VR?BuxttR^mhK|z)bTDsMy7pgA=S{((15)9-n(2CJkMsm zq<-LWxk`H#6>(qMp`RKjzq40SD(eY&qC=2GQ=D^H*Sc4+&8_n~L%lnVYXpaHc=Lv* zPIJnHPg|AMiH$c9577HU6!>-iHiIUdv(H$9l3-8;I>^2uq!yXS%)xsEL{<76)s5O2 zY*=uGo^35X`i;PMU#ZQlgYLBx+#MrH(ct$Nw62QtE{z7k<4s-l_4P&(T~XJ16B8Tu zA>`$u$dx4yhd-MgM64hA)4;dApBEDo<0!bEi`?Jf%z*b%!m>J(IVdWM?;&eP#o6V5f-NHvG3LBEY>{Z4ptQC~5ATC!xZ daLIlHy<;zmv(`O+R#4IbxT)p2^0SEB{{UrLth)dJ literal 0 HcmV?d00001 diff --git a/resources/resources.qrc b/resources/resources.qrc index 277d6a9..98e5a91 100644 --- a/resources/resources.qrc +++ b/resources/resources.qrc @@ -2,6 +2,7 @@ styles/main.qss + icons/openmix.png icons/chevron-up.svg icons/chevron-down.svg diff --git a/src/main.cpp b/src/main.cpp index f1540ff..01fb138 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,6 +3,7 @@ #include "ui/WelcomeDialog.h" #include "ui/theme/Theme.h" #include +#include #include #include #include @@ -37,6 +38,7 @@ int main(int argc, char* argv[]) { QApplication::setApplicationName("OpenMix"); QApplication::setApplicationVersion("0.1.0"); QApplication::setOrganizationName("OpenMix"); + QApplication::setWindowIcon(QIcon(":/icons/openmix.png")); oclero::qlementine::icons::initializeIconTheme();