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: 1 addition & 1 deletion 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.3
VERSION 1.0.4
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
5 changes: 4 additions & 1 deletion resources/styles/main.qss
Original file line number Diff line number Diff line change
Expand Up @@ -239,14 +239,17 @@ QLineEdit[placeholderText] {
}

/* inline editors inside item views must fit their row: the input box model
above (padding + min-height) otherwise overflows compact rows */
above (padding + min-height) otherwise overflows compact rows. The explicit
background keeps them opaque — the view repaints the cell text beneath an
open editor, and a translucent editor shows both texts superimposed */
QTreeView QLineEdit,
QTableView QLineEdit,
QListView QLineEdit {
border: none;
border-radius: 0;
padding: 0 4px;
min-height: 0;
background-color: #1a1c20;
}

QComboBox::drop-down {
Expand Down
13 changes: 13 additions & 0 deletions src/app/Application.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,22 @@ void Application::initialize() {
m_playbackEngine->setChannelGangs(m_show->channelGangs());
m_playbackEngine->setDimDcaFaders(m_show->dimDcaFaders());
m_playbackEngine->setMuteDcaUnassign(m_show->muteDcaUnassign());
m_playbackEngine->setInactiveDcas(m_show->inactiveDcas());

// 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());
m_playbackEngine->setInactiveDcas(m_show->inactiveDcas());
emit activeDcasChanged();
});

// live edits from the mapping panel
connect(m_show, &Show::activeDcasChanged, this, [this]() {
m_playbackEngine->setInactiveDcas(m_show->inactiveDcas());
emit activeDcasChanged();
});

// effective DCA count follows the selected console type, project loads,
Expand Down Expand Up @@ -399,6 +408,10 @@ MixerCapabilities Application::effectiveCapabilities() const {
return MixerCapabilities::forProtocolId(m_show->mixerConfig().type);
}

QSet<int> Application::inactiveDcas() const { return m_show->inactiveDcas(); }

bool Application::isDcaActive(int dca) const { return m_show->isDcaActive(dca); }

void Application::refreshDcaCount() {
const int count = effectiveDcaCount();
if (count == m_lastDcaCount)
Expand Down
6 changes: 6 additions & 0 deletions src/app/Application.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include "protocol/MixerCapabilities.h"
#include <QObject>
#include <QPointer>
#include <QSet>
#include <QUndoStack>

namespace OpenMix {
Expand Down Expand Up @@ -97,6 +98,10 @@ class Application : public QObject {
[[nodiscard]] MixerCapabilities effectiveCapabilities() const;
[[nodiscard]] int effectiveDcaCount() const { return effectiveCapabilities().dcaCount; }

// the show's active-DCA subset (see Show::inactiveDcas)
[[nodiscard]] QSet<int> inactiveDcas() const;
[[nodiscard]] bool isDcaActive(int dca) const;

// main window
void setMainWindow(MainWindow* window);
[[nodiscard]] MainWindow* mainWindow();
Expand All @@ -117,6 +122,7 @@ class Application : public QObject {
void mixerDisconnected();
void recordFadersActiveChanged(bool active);
void dcaCountChanged(int count);
void activeDcasChanged();

private:
void setupMixerConnection(const QString& type, const QString& host, int port);
Expand Down
60 changes: 54 additions & 6 deletions src/core/ActorProfileLibrary.cpp
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
#include "ActorProfileLibrary.h"
#include "Ensemble.h"

#include <QJsonArray>

#include <algorithm>

namespace OpenMix {

ActorProfileLibrary::ActorProfileLibrary(QObject* parent) : QObject(parent) {}
Expand Down Expand Up @@ -75,14 +78,59 @@ const Actor* ActorProfileLibrary::resolveActor(const QString& text) const {
return byRole ? byRole : byName;
}

QStringList ActorProfileLibrary::completionCandidates() const {
QList<int> ActorProfileLibrary::resolveChannels(const QString& text,
const EnsembleLibrary* ensembles) const {
const QString needle = text.trimmed();
if (needle.isEmpty())
return {};

QList<int> channels;
auto collectActive = [&](auto&& matches) {
for (const Actor& a : m_actors) {
if (a.active() && a.channel() >= 1 && matches(a))
channels.append(a.channel());
}
};

collectActive([&](const Actor& a) { return !a.matchedRole(needle).isEmpty(); });

if (channels.isEmpty() && ensembles) {
for (const Ensemble& e : ensembles->ensembles()) {
if (needle.compare(e.name(), Qt::CaseInsensitive) == 0) {
channels = e.channels(); // already sorted/unique/positive
break;
}
}
}

if (channels.isEmpty())
collectActive(
[&](const Actor& a) { return needle.compare(a.name(), Qt::CaseInsensitive) == 0; });

if (channels.isEmpty()) {
if (const Actor* fallback = resolveActor(needle); fallback && fallback->channel() >= 1)
channels.append(fallback->channel());
}

std::sort(channels.begin(), channels.end());
channels.erase(std::unique(channels.begin(), channels.end()), channels.end());
return channels;
}

QStringList ActorProfileLibrary::completionCandidates(const EnsembleLibrary* ensembles) const {
QStringList candidates;
auto add = [&candidates](const QString& text) {
if (!text.isEmpty() && !candidates.contains(text, Qt::CaseInsensitive))
candidates.append(text);
};
for (const Actor& a : m_actors) {
const QStringList texts = a.roles() + QStringList{a.name()};
for (const QString& text : texts) {
if (!text.isEmpty() && !candidates.contains(text, Qt::CaseInsensitive))
candidates.append(text);
}
for (const QString& role : a.roles())
add(role);
add(a.name());
}
if (ensembles) {
for (const Ensemble& e : ensembles->ensembles())
add(e.name());
}
candidates.sort(Qt::CaseInsensitive);
return candidates;
Expand Down
15 changes: 13 additions & 2 deletions src/core/ActorProfileLibrary.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

namespace OpenMix {

class EnsembleLibrary;

// 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.
Expand Down Expand Up @@ -38,8 +40,17 @@ class ActorProfileLibrary : public QObject {
// actor's roles first, then actor name; among duplicates prefer active,
// then lowest order
[[nodiscard]] const Actor* resolveActor(const QString& text) const;
// roles + actor names for autocomplete (non-empty, deduplicated, sorted)
[[nodiscard]] QStringList completionCandidates() const;
// resolve free text to the input channels it names, in tiers:
// 1. every ACTIVE actor holding the role (case-insensitive)
// 2. else the identically-named ensemble's member channels
// 3. else every ACTIVE actor with that name
// 4. else the single resolveActor() fallback (covers inactive understudies)
// sorted, deduplicated, excludes unassigned channels (< 1)
[[nodiscard]] QList<int> resolveChannels(const QString& text,
const EnsembleLibrary* ensembles = nullptr) const;
// roles + actor names (+ ensemble names) for autocomplete (non-empty,
// deduplicated, sorted)
[[nodiscard]] QStringList completionCandidates(const EnsembleLibrary* ensembles = nullptr) const;
void addActor(const Actor& actor);
void updateActor(const QString& id, const Actor& actor);
void removeActor(const QString& id);
Expand Down
13 changes: 13 additions & 0 deletions src/core/Cue.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,19 @@ void Cue::assignChannelToDCAMapping(int channel, int dca, const DCAMapping* seed
setDCAChannelMapping(channelMap);
}

void Cue::assignChannelsToDCAMapping(const QList<int>& channels, int dca,
const DCAMapping* seedFrom) {
if (!hasCustomDCAMapping() && seedFrom)
copyDCAMappingFrom(seedFrom);

QMap<int, QList<int>> channelMap = dcaChannelMapping();
for (QList<int>& list : channelMap) // one DCA per channel
for (int ch : channels)
list.removeAll(ch);
channelMap[dca] = channels; // the label declares the DCA's membership
setDCAChannelMapping(channelMap);
}

void Cue::mergeContentFrom(const Cue& other) {
// scalar content overwritten from the other cue
m_type = other.m_type;
Expand Down
5 changes: 5 additions & 0 deletions src/core/Cue.h
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ class Cue {
// any other DCA); seeds the custom mapping from the show mapping first if
// the cue has none
void assignChannelToDCAMapping(int channel, int dca, const class DCAMapping* seedFrom);
// replace this DCA's channel list in the cue's custom mapping with exactly
// `channels` (each also removed from any other DCA); seeds from the show
// mapping first when the cue has none
void assignChannelsToDCAMapping(const QList<int>& channels, int dca,
const class DCAMapping* seedFrom);

[[nodiscard]] bool isMacro() const noexcept { return m_type == CueType::Macro; }
[[nodiscard]] QStringList childCueIds() const { return m_childCueIds; }
Expand Down
45 changes: 38 additions & 7 deletions src/core/PlaybackEngine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,9 @@ void PlaybackEngine::executeCueInternal(const Cue& cue) {
~DepthGuard() { --depth; }
} depthGuard{m_fireDepth};

// resolve which DCAs to target
QSet<int> targetDCAs = cue.targetsAllDCAs() ? allDCAs() : cue.targetedDCAs();
// resolve which DCAs to target; inactive DCAs are reserved for manual
// console control and never written to
QSet<int> targetDCAs = (cue.targetsAllDCAs() ? allDCAs() : cue.targetedDCAs()) - m_inactiveDcas;

switch (cue.type()) {
case CueType::Snapshot: {
Expand Down Expand Up @@ -353,6 +354,10 @@ void PlaybackEngine::applyDCAOverrides(const Cue& cue, const QSet<int>& targetDC
for (int dca : targetDCAs) {
if (dca < 1 || dca > caps.dcaCount)
continue;
// callers already subtract inactive DCAs; keep the guard local too so
// no future call site can write to a reserved DCA
if (m_inactiveDcas.contains(dca))
continue;

DCAOverride override = cue.dcaOverride(dca);

Expand Down Expand Up @@ -532,7 +537,8 @@ void PlaybackEngine::verifyCue(int index, const Cue& cue) {
return;

// verify the generic snapshot params (sent instantly via recallSnapshot).
const QSet<int> targetDCAs = cue.targetsAllDCAs() ? allDCAs() : cue.targetedDCAs();
const QSet<int> targetDCAs =
(cue.targetsAllDCAs() ? allDCAs() : cue.targetedDCAs()) - m_inactiveDcas;
const QJsonObject params = filterParametersForDCAs(cue, targetDCAs);

// only numeric params (faders/levels/toggles) are verifiable by value;
Expand Down Expand Up @@ -597,16 +603,28 @@ QList<int> PlaybackEngine::getBusesForDCA(const Cue& cue, int dca) const {

QJsonObject PlaybackEngine::filterParametersForDCAs(const Cue& cue,
const QSet<int>& targetDCAs) const {
// any captured /dca/N/... param of an inactive DCA must never reach the
// console, regardless of mapping state
static QRegularExpression dcaAnyRegex("^/dca/(\\d+)/");
auto inactiveDcaParam = [this](const QString& path) {
const QRegularExpressionMatch m = dcaAnyRegex.match(path);
return m.hasMatch() && m_inactiveDcas.contains(m.captured(1).toInt());
};

// without a mapping there is nothing to relate channels to DCAs, so no
// channel filtering happens. An empty target set with a mapping is real
// (e.g. every targeted DCA is inactive) and falls through to the filter
// below, which then drops every mapped channel/bus.
bool hasMapping = cue.hasCustomDCAMapping() || m_dcaMapping;
if (!hasMapping || targetDCAs.isEmpty()) {
if (!hasMapping) {
QJsonObject filtered;
static QRegularExpression dcaFaderRegex("^/dca/\\d+/fader$");

// 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()) {
if (!dcaFaderRegex.match(it.key()).hasMatch() && !inactiveDcaParam(it.key())) {
filtered[it.key()] = it.value();
}
}
Expand Down Expand Up @@ -639,7 +657,7 @@ QJsonObject PlaybackEngine::filterParametersForDCAs(const Cue& cue,
for (auto it = params.begin(); it != params.end(); ++it) {
const QString& path = it.key();

if (dcaFaderRegex.match(path).hasMatch()) {
if (dcaFaderRegex.match(path).hasMatch() || inactiveDcaParam(path)) {
continue;
}

Expand Down Expand Up @@ -764,7 +782,20 @@ void PlaybackEngine::executeStopCue(const Cue& cue) {

case StopBehavior::StopAndApply:
if (m_mixer && !cue.parameters().isEmpty()) {
m_mixer->recallSnapshot(cue);
// this path recalls the raw snapshot; strip captured params of
// inactive DCAs so reserved DCAs stay untouched
static QRegularExpression dcaAnyRegex("^/dca/(\\d+)/");
QJsonObject params;
const QJsonObject original = cue.parameters();
for (auto it = original.begin(); it != original.end(); ++it) {
const QRegularExpressionMatch m = dcaAnyRegex.match(it.key());
if (m.hasMatch() && m_inactiveDcas.contains(m.captured(1).toInt()))
continue;
params[it.key()] = it.value();
}
Cue filteredCue = cue;
filteredCue.setParameters(params);
m_mixer->recallSnapshot(filteredCue);
}
setState(PlaybackState::Running);
emit cueCompleted(m_currentIndex);
Expand Down
5 changes: 5 additions & 0 deletions src/core/PlaybackEngine.h
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ class PlaybackEngine : public QObject {
// DCA count assumed while no mixer is connected (from the configured console)
void setFallbackDcaCount(int count) { m_fallbackDcaCount = count; }

// DCAs OpenMix must not write to on the console (reserved for manual
// control; mirrors Show::inactiveDcas)
void setInactiveDcas(const QSet<int>& dcas) { m_inactiveDcas = dcas; }

// channel mute state driven by MIDI mute buttons (console-style mute toggle)
[[nodiscard]] bool isChannelMuted(int channel) const { return m_channelMutes.value(channel); }

Expand Down Expand Up @@ -182,6 +186,7 @@ class PlaybackEngine : public QObject {
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
int m_fallbackDcaCount = 8; // disconnected-state DCA count
QSet<int> m_inactiveDcas; // DCAs reserved for manual console control
QHash<int, bool> m_channelMutes; // channel -> mute state (MIDI mute buttons)
PlaybackState m_state = PlaybackState::Stopped;
int m_currentIndex = -1;
Expand Down
Loading
Loading