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] diff --git a/CMakeLists.txt b/CMakeLists.txt index 4e6a093..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 ) @@ -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() 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). 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"