From e8f843f8e5518d9e6c2c9dade233b3ba0282b89d Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sun, 12 Jul 2026 13:05:56 -0400 Subject: [PATCH 1/5] fix: keep OSC send and receive on one socket so console replies arrive OscTransport sent requests through liblo's lo_send, which opens its own socket with its own ephemeral source port, but listened for replies on a separate QUdpSocket. X32/M32 and WING reply to the source port of each request, so every reply landed on liblo's socket and was never read. The /xinfo handshake timed out with "no response from mixer" and the client looped reconnecting against a console that was reachable the whole time. Serialise with liblo but send via the same QUdpSocket used to receive, so the request's source port is the one replies come back to. Normalise the target to IPv4 and bind AnyIPv4 to stop the ::ffff:-mapped address flip seen during reconnection. Add a regression test asserting a reply to the request's source port reaches the transport; it fails against the split-socket behaviour. --- src/protocol/transport/OscTransport.cpp | 64 +++++++++++-------- src/protocol/transport/OscTransport.h | 4 +- tests/CMakeLists.txt | 8 +++ tests/test_osc_transport.cpp | 81 +++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 25 deletions(-) create mode 100644 tests/test_osc_transport.cpp diff --git a/src/protocol/transport/OscTransport.cpp b/src/protocol/transport/OscTransport.cpp index 7ac726c..7fbb1c2 100644 --- a/src/protocol/transport/OscTransport.cpp +++ b/src/protocol/transport/OscTransport.cpp @@ -1,5 +1,6 @@ #include "OscTransport.h" #include +#include #include namespace OpenMix { @@ -18,16 +19,18 @@ bool OscTransport::connect(const QString& host, int port) { m_host = host; m_port = port; - m_oscAddress = - lo_address_new(host.toUtf8().constData(), QString::number(port).toUtf8().constData()); - if (!m_oscAddress) { - emit connectionError("Failed to create OSC address"); + m_target = QHostAddress(host); + bool isIPv4 = false; + const quint32 v4 = m_target.toIPv4Address(&isIPv4); + if (isIPv4) { + m_target = QHostAddress(v4); + } + if (m_target.isNull()) { + emit connectionError("Invalid mixer address"); return false; } - if (!m_socket.bind(QHostAddress::Any, 0)) { - lo_address_free(m_oscAddress); - m_oscAddress = nullptr; + if (!m_socket.bind(QHostAddress::AnyIPv4, 0)) { emit connectionError("Failed to bind UDP socket"); return false; } @@ -38,42 +41,55 @@ bool OscTransport::connect(const QString& host, int port) { } void OscTransport::disconnect() { - if (m_oscAddress) { - lo_address_free(m_oscAddress); - m_oscAddress = nullptr; - } - m_socket.close(); m_connected = false; emit disconnected(); } void OscTransport::send(const QString& path) { - if (!m_oscAddress) - return; - lo_send(m_oscAddress, path.toUtf8().constData(), nullptr); + lo_message msg = lo_message_new(); + sendMessage(path, msg); + lo_message_free(msg); } void OscTransport::send(const QString& path, float value) { - if (!m_oscAddress) - return; - lo_send(m_oscAddress, path.toUtf8().constData(), "f", value); + lo_message msg = lo_message_new(); + lo_message_add_float(msg, value); + sendMessage(path, msg); + lo_message_free(msg); } void OscTransport::send(const QString& path, int value) { - if (!m_oscAddress) - return; - lo_send(m_oscAddress, path.toUtf8().constData(), "i", value); + lo_message msg = lo_message_new(); + lo_message_add_int32(msg, value); + sendMessage(path, msg); + lo_message_free(msg); } void OscTransport::send(const QString& path, const QString& value) { - if (!m_oscAddress) + lo_message msg = lo_message_new(); + lo_message_add_string(msg, value.toUtf8().constData()); + sendMessage(path, msg); + lo_message_free(msg); +} + +void OscTransport::sendMessage(const QString& path, lo_message msg) { + if (!m_connected || !msg) return; - lo_send(m_oscAddress, path.toUtf8().constData(), "s", value.toUtf8().constData()); + + const QByteArray pathBytes = path.toUtf8(); + size_t length = 0; + void* buffer = lo_message_serialise(msg, pathBytes.constData(), nullptr, &length); + if (!buffer) + return; + + m_socket.writeDatagram(static_cast(buffer), static_cast(length), m_target, + static_cast(m_port)); + std::free(buffer); } void OscTransport::send(const QString& path, const QVariant& value) { - if (!m_oscAddress) + if (!m_connected) return; switch (value.typeId()) { diff --git a/src/protocol/transport/OscTransport.h b/src/protocol/transport/OscTransport.h index d4be171..e51670b 100644 --- a/src/protocol/transport/OscTransport.h +++ b/src/protocol/transport/OscTransport.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -39,11 +40,12 @@ class OscTransport : public QObject { void onReadyRead(); private: + void sendMessage(const QString& path, lo_message msg); void parseOscMessage(const QByteArray& data); QVariant parseOscArgument(const QByteArray& data, int& offset, char type); - lo_address m_oscAddress = nullptr; QUdpSocket m_socket; + QHostAddress m_target; QString m_host; int m_port = 0; bool m_connected = false; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9a9c50a..60c4717 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -198,6 +198,14 @@ target_link_libraries(test_osc_remote PRIVATE Qt6::Core Qt6::Test openmix_liblo) target_include_directories(test_osc_remote PRIVATE ${CMAKE_SOURCE_DIR}/src) add_test(NAME OscRemoteTest COMMAND test_osc_remote) +add_executable(test_osc_transport + test_osc_transport.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/transport/OscTransport.cpp +) +target_link_libraries(test_osc_transport PRIVATE Qt6::Core Qt6::Network Qt6::Test openmix_liblo) +target_include_directories(test_osc_transport PRIVATE ${CMAKE_SOURCE_DIR}/src) +add_test(NAME OscTransportTest COMMAND test_osc_transport) + add_executable(test_qlab_client test_qlab_client.cpp ${CMAKE_SOURCE_DIR}/src/app/QLabClient.cpp diff --git a/tests/test_osc_transport.cpp b/tests/test_osc_transport.cpp new file mode 100644 index 0000000..2d90812 --- /dev/null +++ b/tests/test_osc_transport.cpp @@ -0,0 +1,81 @@ +#include "protocol/transport/OscTransport.h" +#include +#include +#include +#include +#include +#include + +using namespace OpenMix; + +namespace { +QByteArray oscBytes(const char* path, lo_message msg) { + size_t len = 0; + void* buf = lo_message_serialise(msg, path, nullptr, &len); + QByteArray out(static_cast(buf), static_cast(len)); + std::free(buf); + return out; +} +} + +class TestOscTransport : public QObject { + Q_OBJECT + + private slots: + void replyToRequestSourcePortIsReceived() { + QUdpSocket mixer; + QVERIFY(mixer.bind(QHostAddress::LocalHost, 0)); + const quint16 mixerPort = mixer.localPort(); + + QObject::connect(&mixer, &QUdpSocket::readyRead, [&mixer]() { + while (mixer.hasPendingDatagrams()) { + const QNetworkDatagram in = mixer.receiveDatagram(); + lo_message msg = lo_message_new(); + lo_message_add_string(msg, "X32"); + const QByteArray reply = oscBytes("/xinfo", msg); + lo_message_free(msg); + mixer.writeDatagram(reply, in.senderAddress(), in.senderPort()); + } + }); + + OscTransport transport; + QSignalSpy rx(&transport, &OscTransport::messageReceived); + QVERIFY(transport.connect("127.0.0.1", mixerPort)); + transport.send("/xinfo"); + + QVERIFY(rx.wait(2000)); + QCOMPARE(rx.count(), 1); + QCOMPARE(rx.at(0).at(0).toString(), QStringLiteral("/xinfo")); + QCOMPARE(rx.at(0).at(1).toString(), QStringLiteral("X32")); + } + + void sendEmitsWellFormedOsc() { + QUdpSocket mixer; + QVERIFY(mixer.bind(QHostAddress::LocalHost, 0)); + const quint16 mixerPort = mixer.localPort(); + QSignalSpy wire(&mixer, &QUdpSocket::readyRead); + + OscTransport transport; + QVERIFY(transport.connect("127.0.0.1", mixerPort)); + transport.send("/ch/01/mix/on", 1); + + QVERIFY(wire.wait(2000)); + const QNetworkDatagram dg = mixer.receiveDatagram(); + const QByteArray data = dg.data(); + + const int typeStart = data.indexOf('\0'); + QCOMPARE(data.left(typeStart), QByteArray("/ch/01/mix/on")); + QCOMPARE(data.size() % 4, 0); + + const int tagAt = ((typeStart + 4) / 4) * 4; + QCOMPARE(data.mid(tagAt, 2), QByteArray(",i")); + + const int argAt = data.size() - 4; + const quint32 arg = (quint8(data[argAt]) << 24) | (quint8(data[argAt + 1]) << 16) | + (quint8(data[argAt + 2]) << 8) | quint8(data[argAt + 3]); + QCOMPARE(arg, 1u); + } +}; + +QTEST_MAIN(TestOscTransport) +#include "test_osc_transport.moc" From 97026e066b59d89f57c285cbbcbcc41e035a1ec0 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sun, 12 Jul 2026 13:05:56 -0400 Subject: [PATCH 2/5] chore: strip gcc-only flag so clangd can parse the tree CachyOS builds Qt6 with -mno-direct-extern-access, which rides the Qt6::Platform interface target onto every translation unit. clang rejects the flag, so clangd bailed on line 1 of every file. Drop it via .clangd; the gcc build is unaffected. --- .clangd | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .clangd diff --git a/.clangd b/.clangd new file mode 100644 index 0000000..3621d02 --- /dev/null +++ b/.clangd @@ -0,0 +1,2 @@ +CompileFlags: + Remove: [-mno-direct-extern-access] From f9cbd334a34db6890ca48d31504f551a8a1fbe7c Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sun, 12 Jul 2026 13:05:56 -0400 Subject: [PATCH 3/5] build: set RPM package license to GPLv3 The project ships under GPLv3 (see LICENSE), but CPACK_RPM_PACKAGE_LICENSE still declared MIT. Correct the packaged metadata to match. --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 4e6a093..a59ba49 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -507,7 +507,7 @@ 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_LICENSE "GPLv3") set(CPACK_RPM_PACKAGE_GROUP "Applications/Multimedia") endif() From 0578f514e4c4e3e8a0ed63c59f483117fc323387 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sun, 12 Jul 2026 13:05:56 -0400 Subject: [PATCH 4/5] docs: add README --- README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..582c069 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +

OpenMix

+ +Build +Release +License + +OpenMix is stage mixing software for live theatre. A show is programmed as a cue list; each cue assigns performers to DCAs, sets levels and mutes, and pushes scribble strip names to the console over the network. Playback, fades, and console state all run in OpenMix, so the console itself needs no show-specific programming. It runs on Windows, macOS, and Linux and drives desks from Behringer, Midas, Allen & Heath, Yamaha, and DiGiCo. + +## Architecture + +``` +src/ + core/ show model and engines: Cue, CueList, Show, FadeEngine, + PlaybackEngine, DCAMapping, actors/roles/ensembles, undo + protocol/ console abstraction layer (see below) + midi/ MIDI input: MSC and MTC parsing, control mapping (RtMidi) + io/ project files, autosave, crash recovery, .tmix import + app/ application shell, auto-update, external cue software + clients (QLab, REAPER, SCS, CuePlayer), OSC remote server + ui/ Qt Widgets UI +tests/ Qt Test suites, one binary per area, run via ctest +libs/ vendored RtMidi +``` + +The protocol layer is the main extension point: + +- `MixerProtocol` is the abstract console interface. One subclass per console family under `protocol/behringer/`, `protocol/allenheath/`, `protocol/yamaha/`, `protocol/digico/`. Wire formats vary per family: OSC over UDP (X32/M32, WING), MIDI over TCP (SQ, GLD), binary TCP (Avantis, dLive, DiGiCo SD), text TCP (Yamaha). +- `MixerCapabilities` describes each model: channel and DCA counts, protocol type, port. Adding a model that speaks an existing protocol is mostly a capabilities entry. +- `ProtocolFactory` maps a `ConsoleType` to a protocol instance. +- `protocol/transport/` holds the shared OSC (liblo) and TCP transports; `protocol/discovery/` implements network auto-discovery with per-family probe strategies. +- `LoopbackProtocol` is an in-process fake console shared by the test suite and dry-run mode, so cue playback is testable without hardware. + +## Technical notes + +- Show files (`.omproj`) are indented JSON, written by `ProjectFile` (src/io/ProjectFile.cpp). They diff cleanly, so keeping a show in version control works. `.tmix` and `.x32tc` show files can be imported. +- `FadeEngine` runs on a 20 ms timer but computes levels from measured elapsed time, not tick counts, so fades stay on schedule even when the event loop stalls. +- Engines and UI live on the Qt main thread. Background threads exist only at the I/O edges (liblo's OSC server threads, RtMidi's input callback), and both hand their events to the main thread through queued Qt signals. +- `OscRemoteServer` exposes transport control over UDP: `/go`, `/stop`, `/next`, `/prev`, `/cue/goto `, `/ctrl/fadeall`. +- Autosave snapshots the show every 5 minutes; `CrashRecovery` additionally persists playback position and mixer state, so a crash mid-show restores to the current cue, not just the last save. + +## License + +OpenMix is free software, released under the [GNU General Public License v3](LICENSE). From 99aa7b02b95057aa2ae816c6258fa94a1d0ac2c1 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sun, 12 Jul 2026 13:08:08 -0400 Subject: [PATCH 5/5] chore: bump version to 1.0.5 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index a59ba49..478dd24 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.16) project(OpenMix - VERSION 1.0.4 + VERSION 1.0.5 DESCRIPTION "Stage mixing software for live theatre that lets engineers program, automate, and run cues seamlessly across 30+ digital mixing consoles" LANGUAGES CXX )