Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .clangd
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CompileFlags:
Remove: [-mno-direct-extern-access]
4 changes: 2 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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
)
Expand Down Expand Up @@ -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()

Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<h1>OpenMix</h1>

<a href="https://github.com/OpenMix-dev/OpenMix/actions/workflows/build.yml"><img src="https://github.com/OpenMix-dev/OpenMix/actions/workflows/build.yml/badge.svg" alt="Build"></a>
<a href="https://github.com/OpenMix-dev/OpenMix/releases/latest"><img src="https://img.shields.io/github/v/release/OpenMix-dev/OpenMix" alt="Release"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/OpenMix-dev/OpenMix" alt="License"></a>

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 <n>`, `/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).
64 changes: 40 additions & 24 deletions src/protocol/transport/OscTransport.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "OscTransport.h"
#include <QNetworkDatagram>
#include <cstdlib>
#include <cstring>

namespace OpenMix {
Expand All @@ -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;
}
Expand All @@ -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<const char*>(buffer), static_cast<qint64>(length), m_target,
static_cast<quint16>(m_port));
std::free(buffer);
}

void OscTransport::send(const QString& path, const QVariant& value) {
if (!m_oscAddress)
if (!m_connected)
return;

switch (value.typeId()) {
Expand Down
4 changes: 3 additions & 1 deletion src/protocol/transport/OscTransport.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#pragma once

#include <QHostAddress>
#include <QObject>
#include <QTimer>
#include <QUdpSocket>
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions tests/test_osc_transport.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#include "protocol/transport/OscTransport.h"
#include <QNetworkDatagram>
#include <QSignalSpy>
#include <QUdpSocket>
#include <QtTest/QtTest>
#include <cstdlib>
#include <lo/lo.h>

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<const char*>(buf), static_cast<int>(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"
Loading