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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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 + +