From aeed28ad77c09538621be3ac344e2e2303255986 Mon Sep 17 00:00:00 2001 From: Alexandre Quessy Date: Fri, 26 Jun 2026 01:06:10 -0400 Subject: [PATCH 1/3] Refactor OSC handling into a testable parser (OscAction) Split OSC address parsing from its side effects so it can be unit-tested without a running GUI: - Add OscAction + parseOscAction(): a pure function turning an OSC address and its arguments into a structured, fully-resolved command. - Rewrite OscInterface to call the parser and a new applyAction() that resolves the target source/layer against the MappingManager and mutates the model. Behaviour is preserved for the existing addresses; new commands are added in the parser (per-source play/pause, layer move/translate/vertex). Also fixes a latent null-pointer deref when an unknown source id was targeted, and stops MappingManager::getSourceById() from inserting null entries. --- src/control/OscAction.cpp | 203 ++++++++++++++++++++++++++ src/control/OscAction.h | 101 +++++++++++++ src/control/OscInterface.cpp | 272 +++++++++++++++++++---------------- src/control/OscInterface.h | 7 +- src/control/control.pri | 6 +- 5 files changed, 461 insertions(+), 128 deletions(-) create mode 100644 src/control/OscAction.cpp create mode 100644 src/control/OscAction.h diff --git a/src/control/OscAction.cpp b/src/control/OscAction.cpp new file mode 100644 index 00000000..3ca4e107 --- /dev/null +++ b/src/control/OscAction.cpp @@ -0,0 +1,203 @@ +/* + * OscAction.cpp + * + * (c) 2026 Alexandre Quessy -- alexandre(@)quessy(.)net + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "OscAction.h" + +#include + +namespace mmp { + +namespace { + +OscAction invalid(const QString& reason) +{ + OscAction a; + a.type = OscAction::Invalid; + a.error = reason; + return a; +} + +// Fills in the target selector of `a` from the first OSC argument. +// A string argument selects element(s) by (wildcard) name; any other type +// selects a single element by integer id. +void parseSelector(OscAction& a, const QVariant& selector) +{ + if (selector.typeId() == QMetaType::QString) + { + a.selectByName = true; + a.name = selector.toString(); + } + else + { + a.selectByName = false; + a.id = selector.toInt(); + } +} + +OscAction parseSource(const QStringList& sub, const QVariantList& args) +{ + // Every per-source command needs at least the target selector. + if (args.isEmpty()) + return invalid("source command is missing its target (id or name)"); + + if (sub.isEmpty()) + return invalid("source command is missing a subcommand"); + + OscAction a; + parseSelector(a, args.at(0)); + + if (sub.size() == 1 && sub.at(0) == QLatin1String("play")) + { + a.type = OscAction::SourcePlay; + return a; + } + if (sub.size() == 1 && sub.at(0) == QLatin1String("pause")) + { + a.type = OscAction::SourcePause; + return a; + } + if (sub.size() == 1 && sub.at(0) == QLatin1String("rewind")) + { + a.type = OscAction::SourceRewind; + return a; + } + + // Otherwise a single token is treated as a property name to set. + if (sub.size() == 1) + { + if (args.size() < 2) + return invalid("source property '" + sub.at(0) + "' is missing its value"); + a.type = OscAction::SourceProperty; + a.property = sub.at(0); + a.value = args.at(1); + return a; + } + + return invalid("unknown source subcommand: " + sub.join('/')); +} + +OscAction parseLayer(const QStringList& sub, const QVariantList& args) +{ + if (args.isEmpty()) + return invalid("layer command is missing its target (id or name)"); + + if (sub.isEmpty()) + return invalid("layer command is missing a subcommand"); + + OscAction a; + parseSelector(a, args.at(0)); + + // Absolute move: /mapmap/layer/move/xy + if (sub.size() == 2 && sub.at(0) == QLatin1String("move") && sub.at(1) == QLatin1String("xy")) + { + if (args.size() < 3) + return invalid("layer move/xy needs "); + a.type = OscAction::LayerMove; + a.x = args.at(1).toDouble(); + a.y = args.at(2).toDouble(); + return a; + } + + // Relative translate: /mapmap/layer/translate/xy + if (sub.size() == 2 && sub.at(0) == QLatin1String("translate") && sub.at(1) == QLatin1String("xy")) + { + if (args.size() < 3) + return invalid("layer translate/xy needs "); + a.type = OscAction::LayerTranslate; + a.x = args.at(1).toDouble(); + a.y = args.at(2).toDouble(); + return a; + } + + // Vertex edit: /mapmap/layer/vertex[/source|/destination]/xy + if (sub.first() == QLatin1String("vertex") && sub.last() == QLatin1String("xy")) + { + a.shapeRole = OscAction::OutputShape; + bool roleOk = true; + if (sub.size() == 2) // vertex/xy + a.shapeRole = OscAction::OutputShape; + else if (sub.size() == 3 && sub.at(1) == QLatin1String("destination")) + a.shapeRole = OscAction::OutputShape; + else if (sub.size() == 3 && sub.at(1) == QLatin1String("source")) + a.shapeRole = OscAction::InputShape; + else + roleOk = false; + + if (!roleOk) + return invalid("unknown layer vertex subcommand: " + sub.join('/')); + + if (args.size() < 4) + return invalid("layer vertex/xy needs "); + a.type = OscAction::LayerVertex; + a.vertexIndex = args.at(1).toInt(); + a.x = args.at(2).toDouble(); + a.y = args.at(3).toDouble(); + return a; + } + + // Otherwise a single token is treated as a property name to set. + if (sub.size() == 1) + { + if (args.size() < 2) + return invalid("layer property '" + sub.at(0) + "' is missing its value"); + a.type = OscAction::LayerProperty; + a.property = sub.at(0); + a.value = args.at(1); + return a; + } + + return invalid("unknown layer subcommand: " + sub.join('/')); +} + +} // namespace + +OscAction parseOscAction(const QString& address, const QVariantList& args) +{ + // Tokenize the address, dropping empty tokens (leading '/', double '//'). + const QStringList tokens = address.split('/', Qt::SkipEmptyParts); + + if (tokens.isEmpty() || tokens.first() != QLatin1String("mapmap")) + return invalid("address does not start with /mapmap"); + + const QStringList rest = tokens.mid(1); + if (rest.isEmpty()) + return invalid("address has no command after /mapmap"); + + const QString& head = rest.first(); + + // Global transport (no target). + if (rest.size() == 1) + { + if (head == QLatin1String("play")) { OscAction a; a.type = OscAction::PlayAll; return a; } + if (head == QLatin1String("pause")) { OscAction a; a.type = OscAction::PauseAll; return a; } + if (head == QLatin1String("rewind")) { OscAction a; a.type = OscAction::RewindAll; return a; } + if (head == QLatin1String("quit")) { OscAction a; a.type = OscAction::Quit; return a; } + } + + if (head == QLatin1String("source")) + return parseSource(rest.mid(1), args); + + // "mapping" is accepted as an alias of "layer". + if (head == QLatin1String("layer") || head == QLatin1String("mapping")) + return parseLayer(rest.mid(1), args); + + return invalid("unknown command: " + address); +} + +} // namespace mmp diff --git a/src/control/OscAction.h b/src/control/OscAction.h new file mode 100644 index 00000000..24eace1a --- /dev/null +++ b/src/control/OscAction.h @@ -0,0 +1,101 @@ +/* + * OscAction.h + * + * (c) 2026 Alexandre Quessy -- alexandre(@)quessy(.)net + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include + +namespace mmp { + +/** + * A parsed OSC command, ready to be applied to the model. + * + * Parsing an OSC address + arguments into an OscAction is a pure operation + * (no access to the MainWindow or the model), which makes it unit-testable + * without a running GUI. The side effects (resolving the target element and + * mutating it) live in OscInterface::applyAction(). + */ +struct OscAction { + enum Type { + Invalid, + + // Global transport. + Quit, + PlayAll, + PauseAll, + RewindAll, + + // Per-source commands. Target resolved via the selector below. + SourcePlay, + SourcePause, + SourceRewind, + SourceProperty, + + // Per-layer commands. Target resolved via the selector below. + LayerProperty, + LayerMove, ///< Absolute: move the output shape so its center is (x, y). + LayerTranslate, ///< Relative: translate the output shape by (x, y). + LayerVertex, ///< Set one vertex of the input or output shape. + }; + + /// Which shape of a layer a vertex/move command applies to. + enum ShapeRole { + OutputShape, ///< The destination shape (where the content is projected). + InputShape ///< The source shape (the picked region of a texture). + }; + + Type type = Invalid; + + // --- Target selection (for Source*/Layer* commands). --- + // The target is selected either by integer id, or by a name pattern + // (wildcard) matching one or several elements. + bool selectByName = false; + int id = -1; + QString name; + + // --- Payload for *Property commands. --- + QString property; + QVariant value; + + // --- Payload for LayerMove / LayerTranslate / LayerVertex. --- + ShapeRole shapeRole = OutputShape; + int vertexIndex = -1; + double x = 0.0; + double y = 0.0; + + // Human-readable reason, set when type == Invalid (for verbose logging / tests). + QString error; + + bool isValid() const { return type != Invalid; } +}; + +/** + * Parses an OSC address and its arguments into an OscAction. + * + * @param address the full OSC path, e.g. "/mapmap/layer/vertex/source/xy". + * @param args the OSC arguments, NOT including the address or the type tags. + * + * On failure, returns an OscAction whose type is Invalid and whose `error` + * field explains why. + */ +OscAction parseOscAction(const QString& address, const QVariantList& args); + +} // namespace mmp diff --git a/src/control/OscInterface.cpp b/src/control/OscInterface.cpp index 36354679..1e271186 100644 --- a/src/control/OscInterface.cpp +++ b/src/control/OscInterface.cpp @@ -6,6 +6,7 @@ * (c) 2013 Sofian Audry -- info(@)sofianaudry(.)com * (c) 2013 Alexandre Quessy -- alexandre(@)quessy(.)net * (c) 2020 Alexandre Quessy -- alexandre(@)quessy(.)net + * (c) 2026 Alexandre Quessy -- alexandre(@)quessy(.)net * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -24,19 +25,54 @@ #include "OscInterface.h" #include "MainWindow.h" #include +#include +#include namespace mmp { -static const QString OSC_ROOT("mapmap"); -static const QString OSC_SOURCE("source"); -static const QString OSC_LAYER("layer"); -static const QString OSC_QUIT("quit"); -static const QString OSC_PLAY("play"); -static const QString OSC_PAUSE("pause"); -static const QString OSC_REWIND("rewind"); +namespace { -static const QString OSC_SOURCE_MEDIA("media"); -static const QString OSC_SOURCE_COLOR("color"); +/// Returns the source with the given id, or a null pointer, WITHOUT mutating +/// the manager (MappingManager::getSourceById() would insert a null entry for +/// an unknown id, since QMap::operator[] is non-const there). +Source::ptr findSourceById(MappingManager& manager, int id) +{ + for (int i = 0; i < manager.nSources(); ++i) + { + Source::ptr source = manager.getSource(i); + if (!source.isNull() && static_cast(source->getId()) == id) + return source; + } + return Source::ptr(); +} + +/// Resolves the source(s) targeted by an action (by id or by name pattern). +QVector resolveSources(MappingManager& manager, const OscAction& action) +{ + if (action.selectByName) + return manager.getSourcesByNameRegExp(action.name); + + QVector sources; + Source::ptr source = findSourceById(manager, action.id); + if (!source.isNull()) + sources.push_back(source); + return sources; +} + +/// Resolves the layer(s) targeted by an action (by id or by name pattern). +QVector resolveLayers(MappingManager& manager, const OscAction& action) +{ + if (action.selectByName) + return manager.getLayersByNameRegExp(action.name); + + QVector layers; + Layer::ptr layer = manager.getLayerById(action.id); + if (!layer.isNull()) + layers.push_back(layer); + return layers; +} + +} // namespace OscInterface::OscInterface( int listen_port) : @@ -71,9 +107,6 @@ void OscInterface::consume_commands(MainWindow &main_window) success = messaging_queue_.try_pop(command); if (success) { - //if (is_verbose()) - // std::cout << __FUNCTION__ << ": apply " << - // command.first().toString().toStdString() << std::endl; this->applyOscCommand(main_window, command); } } @@ -147,157 +180,148 @@ static void printCommand(QVariantList &command) } void OscInterface::applyOscCommand(MainWindow &main_window, QVariantList & command) { - Q_UNUSED(main_window); - if (is_verbose()) { std::cout << "OscInterface::applyOscCommand: Receive OSC: " << std::endl; printCommand(command); } - // The two first QVariant objects are: path, typeTags + // The two first QVariant objects are: path, typeTags. The rest are the args. if (command.size() < 2) - { return; - } if (command.at(0).typeId() != QMetaType::QString) - { return; - } if (command.at(1).typeId() != QMetaType::QString) - { return; + + const QString path = command.at(0).toString(); + const QVariantList args = command.mid(2); + + OscAction action = parseOscAction(path, args); + bool handled = applyAction(main_window, action); + + if (!handled && is_verbose()) + { + qDebug() << "OSC path could not be processed: " << path + << (action.isValid() ? QString("(no matching target)") : action.error) + << Qt::endl; + printCommand(command); } +} + +bool OscInterface::applyAction(MainWindow &main_window, const OscAction& action) +{ + MappingManager& manager = main_window.getMappingManager(); - QString path = command.at(0).toString(); - QString typetags = command.at(1).toString(); + switch (action.type) + { + case OscAction::Invalid: + return false; - bool pathIsValid = false; - // Walks through each token in the form /mapmap/source/color - The first token is "mapmap", and then "source" - QPair iterator = next(path); + // Global transport. + case OscAction::Quit: main_window.close(); return true; + case OscAction::PlayAll: main_window.play(); return true; + case OscAction::PauseAll: main_window.pause(); return true; + case OscAction::RewindAll: main_window.rewind(); return true; - if (iterator.first.isEmpty()) { - // Check root tag. - iterator = next(iterator.second); - if (iterator.first == OSC_ROOT) + // Per-source commands. + case OscAction::SourcePlay: + case OscAction::SourcePause: + case OscAction::SourceRewind: + case OscAction::SourceProperty: + { + bool handled = false; + for (Source::ptr source : resolveSources(manager, action)) { - // Check type. - iterator = next(iterator.second); - - // Source. - if (iterator.first == OSC_SOURCE) + if (source.isNull()) + continue; + switch (action.type) { - // Find source (or sources). - if (command.size() >= 3) - { - QVector sources; - if (command.at(2).typeId() == QMetaType::QString) - sources = main_window.getMappingManager().getSourcesByNameRegExp(command.at(2).toString()); - else - { - int id = command.at(2).toInt(); - sources.push_back(main_window.getMappingManager().getSourceById(id)); - } - // Process all sources. - iterator = next(iterator.second); - for (Source::ptr elem: sources) - { - // Rewind. - if (iterator.first == OSC_REWIND) - { - elem->rewind(); - pathIsValid = true; - } - // Property setting (eg. opacity) - else if (command.size() >= 4) { - if (is_verbose()) - qDebug() << "Attempt to set a source property" << iterator.first << command.at(3); - pathIsValid |= setElementProperty(elem, iterator.first, command.at(3)); - } - } - } + case OscAction::SourcePlay: source->play(); handled = true; break; + case OscAction::SourcePause: source->pause(); handled = true; break; + case OscAction::SourceRewind: source->rewind(); handled = true; break; + case OscAction::SourceProperty: handled |= setElementProperty(source, action.property, action.value); break; + default: break; } + } + return handled; + } - // Layer. - else if (iterator.first == OSC_LAYER) - { - // Find layer (or layers). - if (command.size() >= 3) - { - QVector layers; - if (command.at(2).typeId() == QMetaType::QString) - layers = main_window.getMappingManager().getLayersByNameRegExp(command.at(2).toString()); - else - { - int id = command.at(2).toInt(); - Layer::ptr layer = main_window.getMappingManager().getLayerById(id); - if (!layer.isNull()) - layers.push_back(layer); - } - // Process all layers (set property). - if (command.size() >= 4) - { - iterator = next(iterator.second); - for (Layer::ptr elem: layers) - { - pathIsValid |= setElementProperty(elem, iterator.first, command.at(3)); - } - } - } - } + // Per-layer commands. + case OscAction::LayerProperty: + case OscAction::LayerMove: + case OscAction::LayerTranslate: + case OscAction::LayerVertex: + { + bool handled = false; + for (Layer::ptr layer : resolveLayers(manager, action)) + { + if (layer.isNull()) + continue; - // Play / pause / rewind / quit. - else if (iterator.first == OSC_PLAY) + if (action.type == OscAction::LayerProperty) { - main_window.play(); + handled |= setElementProperty(layer, action.property, action.value); + continue; } - else if (iterator.first == OSC_PAUSE) + + // The shape that move/translate/vertex operate on. + MShape::ptr shape = (action.shapeRole == OscAction::InputShape) + ? layer->getInputShape() + : layer->getShape(); + if (shape.isNull()) + continue; + + switch (action.type) { - main_window.pause(); - } - else if (iterator.first == OSC_REWIND) + case OscAction::LayerMove: { - main_window.rewind(); + // Absolute: translate so the shape's center lands on (x, y). + const QPointF center = shape->getCenter(); + shape->translate(QPointF(action.x - center.x(), action.y - center.y())); + handled = true; + break; } - else if (iterator.first == OSC_QUIT) - { - main_window.close(); + case OscAction::LayerTranslate: + shape->translate(QPointF(action.x, action.y)); + handled = true; + break; + case OscAction::LayerVertex: + if (action.vertexIndex >= 0 && action.vertexIndex < shape->nVertices()) + { + shape->setVertex(action.vertexIndex, action.x, action.y); + handled = true; + } + break; + default: + break; } } + return handled; } - - if (! pathIsValid && is_verbose()) - { - qDebug() << "Path could not be processed: " << path << Qt::endl; - printCommand(command); } -} - -QPair OscInterface::next(const QString& path) -{ - int idx = path.indexOf('/'); - if (idx >= 0) - { - return QPair(path.left(idx), path.right(path.size() - idx - 1)); - } - else - { - return QPair(path, ""); - } + return false; } bool OscInterface::setElementProperty(const QSharedPointer& elem, const QString& property, const QVariant& value) { if (elem.isNull()) - { return false; - } - else + + const QByteArray name = property.toUtf8(); + + // Colors arrive over OSC as strings ("#ff0000", "red", ...). Convert them to + // a QColor when the target property is a color, since QVariant won't do it. + const QVariant existing = elem->property(name.constData()); + if (existing.isValid() + && existing.typeId() == QMetaType::QColor + && value.typeId() == QMetaType::QString) { - return elem->setProperty(property.toUtf8().data(), value); + return elem->setProperty(name.constData(), QColor(value.toString())); } -} + return elem->setProperty(name.constData(), value); } +} diff --git a/src/control/OscInterface.h b/src/control/OscInterface.h index 36c73f60..93ffa8bc 100644 --- a/src/control/OscInterface.h +++ b/src/control/OscInterface.h @@ -28,6 +28,7 @@ #include "ConcurrentQueue.h" #include "oscreceiver.h" +#include "OscAction.h" namespace mmp { @@ -71,8 +72,10 @@ class OscInterface { // In the main thread, handles the messages. void applyOscCommand(MainWindow &main_window, QVariantList & command); - // For path = "path_item/rest_of_path" returns (path_item, rest_of_path). - static QPair next(const QString& path); + // Applies a parsed action to the model. Returns true iff it matched at least + // one target and did something. Lives here (rather than in OscAction) because + // it needs access to the running MainWindow and its MappingManager. + bool applyAction(MainWindow &main_window, const OscAction& action); void messageReceivedCb(const QString& oscAddress, const QVariantList& arguments); diff --git a/src/control/control.pri b/src/control/control.pri index 7a838313..3a20fce7 100644 --- a/src/control/control.pri +++ b/src/control/control.pri @@ -1,9 +1,11 @@ include(../src.pri) HEADERS += $$PWD/ConcurrentQueue.h \ - $$PWD/OscInterface.h + $$PWD/OscInterface.h \ + $$PWD/OscAction.h -SOURCES += $$PWD/OscInterface.cpp +SOURCES += $$PWD/OscInterface.cpp \ + $$PWD/OscAction.cpp contains(DEFINES, HAVE_MCP) { HEADERS += $$PWD/McpServer.h From 6b27c2c430e4f762f4c62ae68c6cf46ac4335822 Mon Sep 17 00:00:00 2001 From: Alexandre Quessy Date: Fri, 26 Jun 2026 01:08:07 -0400 Subject: [PATCH 2/3] Add unit tests for the OSC command parser Cover parseOscAction() across global transport, per-source transport and properties (by id and by name pattern), layer properties, the "mapping" alias, layer move/translate, vertex edits on the source/destination shapes, and malformed input (missing target, missing value, bad vertex role). Wires src/control/OscAction.cpp into the test target, which links only against QtCore so it needs none of the GUI/multimedia back-ends. --- tests/TestOsc.cpp | 148 ++++++++++++++++++++++++++++++++++++++++++++++ tests/TestOsc.h | 37 ++++++++++++ tests/main.cpp | 5 ++ tests/tests.pro | 15 +++-- 4 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 tests/TestOsc.cpp create mode 100644 tests/TestOsc.h diff --git a/tests/TestOsc.cpp b/tests/TestOsc.cpp new file mode 100644 index 00000000..0f8df2bc --- /dev/null +++ b/tests/TestOsc.cpp @@ -0,0 +1,148 @@ +/* + * TestOsc.cpp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +#include "TestOsc.h" + +#include "OscAction.h" + +using namespace mmp; + +namespace { +// Shorthand: the type of a parsed action, as an int (for nice QCOMPARE output). +int typeOf(const OscAction& a) { return static_cast(a.type); } +} + +void TestOsc::rejectsNonMapMapAddress() +{ + QCOMPARE(typeOf(parseOscAction("/foo/play", {})), int(OscAction::Invalid)); + QCOMPARE(typeOf(parseOscAction("", {})), int(OscAction::Invalid)); + QCOMPARE(typeOf(parseOscAction("/mapmap", {})), int(OscAction::Invalid)); +} + +void TestOsc::parsesGlobalTransport() +{ + QCOMPARE(typeOf(parseOscAction("/mapmap/play", {})), int(OscAction::PlayAll)); + QCOMPARE(typeOf(parseOscAction("/mapmap/pause", {})), int(OscAction::PauseAll)); + QCOMPARE(typeOf(parseOscAction("/mapmap/rewind", {})), int(OscAction::RewindAll)); + QCOMPARE(typeOf(parseOscAction("/mapmap/quit", {})), int(OscAction::Quit)); +} + +void TestOsc::parsesSourceTransport() +{ + OscAction play = parseOscAction("/mapmap/source/play", { 3 }); + QCOMPARE(typeOf(play), int(OscAction::SourcePlay)); + QVERIFY(!play.selectByName); + QCOMPARE(play.id, 3); + + QCOMPARE(typeOf(parseOscAction("/mapmap/source/pause", { 3 })), int(OscAction::SourcePause)); + QCOMPARE(typeOf(parseOscAction("/mapmap/source/rewind", { 3 })), int(OscAction::SourceRewind)); +} + +void TestOsc::parsesSourceProperty() +{ + OscAction a = parseOscAction("/mapmap/source/opacity", { 2, 0.5 }); + QCOMPARE(typeOf(a), int(OscAction::SourceProperty)); + QVERIFY(!a.selectByName); + QCOMPARE(a.id, 2); + QCOMPARE(a.property, QString("opacity")); + QCOMPARE(a.value.toDouble(), 0.5); +} + +void TestOsc::parsesSourceByNamePattern() +{ + OscAction a = parseOscAction("/mapmap/source/opacity", { QString("clip*"), 0.25 }); + QCOMPARE(typeOf(a), int(OscAction::SourceProperty)); + QVERIFY(a.selectByName); + QCOMPARE(a.name, QString("clip*")); + QCOMPARE(a.value.toDouble(), 0.25); +} + +void TestOsc::parsesLayerProperty() +{ + OscAction a = parseOscAction("/mapmap/layer/visible", { 7, true }); + QCOMPARE(typeOf(a), int(OscAction::LayerProperty)); + QCOMPARE(a.id, 7); + QCOMPARE(a.property, QString("visible")); + QCOMPARE(a.value.toBool(), true); +} + +void TestOsc::acceptsMappingAlias() +{ + // "mapping" is an accepted alias for "layer". + OscAction a = parseOscAction("/mapmap/mapping/solo", { 1, false }); + QCOMPARE(typeOf(a), int(OscAction::LayerProperty)); + QCOMPARE(a.property, QString("solo")); +} + +void TestOsc::parsesLayerMoveAndTranslate() +{ + OscAction move = parseOscAction("/mapmap/layer/move/xy", { 4, 0.5, 0.25 }); + QCOMPARE(typeOf(move), int(OscAction::LayerMove)); + QCOMPARE(move.id, 4); + QCOMPARE(move.x, 0.5); + QCOMPARE(move.y, 0.25); + + OscAction tr = parseOscAction("/mapmap/layer/translate/xy", { 4, -0.1, 0.2 }); + QCOMPARE(typeOf(tr), int(OscAction::LayerTranslate)); + QCOMPARE(tr.x, -0.1); + QCOMPARE(tr.y, 0.2); +} + +void TestOsc::parsesLayerVertex() +{ + OscAction src = parseOscAction("/mapmap/layer/vertex/source/xy", { 5, 2, 0.1, 0.9 }); + QCOMPARE(typeOf(src), int(OscAction::LayerVertex)); + QCOMPARE(int(src.shapeRole), int(OscAction::InputShape)); + QCOMPARE(src.vertexIndex, 2); + QCOMPARE(src.x, 0.1); + QCOMPARE(src.y, 0.9); + + OscAction dst = parseOscAction("/mapmap/layer/vertex/destination/xy", { 5, 0, 1.0, 1.0 }); + QCOMPARE(typeOf(dst), int(OscAction::LayerVertex)); + QCOMPARE(int(dst.shapeRole), int(OscAction::OutputShape)); + QCOMPARE(dst.vertexIndex, 0); + + // Without source/destination, the output shape is the default. + OscAction bare = parseOscAction("/mapmap/layer/vertex/xy", { 5, 1, 0.3, 0.3 }); + QCOMPARE(typeOf(bare), int(OscAction::LayerVertex)); + QCOMPARE(int(bare.shapeRole), int(OscAction::OutputShape)); + QCOMPARE(bare.vertexIndex, 1); +} + +void TestOsc::rejectsMissingTarget() +{ + // No selector argument at all. + QCOMPARE(typeOf(parseOscAction("/mapmap/source/play", {})), int(OscAction::Invalid)); + QCOMPARE(typeOf(parseOscAction("/mapmap/source/opacity", {})), int(OscAction::Invalid)); + QCOMPARE(typeOf(parseOscAction("/mapmap/layer/visible", {})), int(OscAction::Invalid)); +} + +void TestOsc::rejectsMissingValue() +{ + // Selector present but no value to set. + QCOMPARE(typeOf(parseOscAction("/mapmap/source/opacity", { 1 })), int(OscAction::Invalid)); + QCOMPARE(typeOf(parseOscAction("/mapmap/layer/visible", { 1 })), int(OscAction::Invalid)); +} + +void TestOsc::rejectsMalformedVertex() +{ + // move/xy needs x and y; here only x is provided. + QCOMPARE(typeOf(parseOscAction("/mapmap/layer/move/xy", { 1, 0.5 })), int(OscAction::Invalid)); + // vertex needs index + x + y. + QCOMPARE(typeOf(parseOscAction("/mapmap/layer/vertex/source/xy", { 1, 2 })), int(OscAction::Invalid)); + // Unknown vertex role. + QCOMPARE(typeOf(parseOscAction("/mapmap/layer/vertex/middle/xy", { 1, 2, 0.0, 0.0 })), int(OscAction::Invalid)); +} + +void TestOsc::ignoresLeadingAndDoubleSlashes() +{ + // Leading slash, no slash, and accidental double slashes all tokenize the same. + QCOMPARE(typeOf(parseOscAction("mapmap/play", {})), int(OscAction::PlayAll)); + QCOMPARE(typeOf(parseOscAction("//mapmap//play", {})), int(OscAction::PlayAll)); +} diff --git a/tests/TestOsc.h b/tests/TestOsc.h new file mode 100644 index 00000000..41886384 --- /dev/null +++ b/tests/TestOsc.h @@ -0,0 +1,37 @@ +/* + * TestOsc.h + * + * Unit tests for src/control/OscAction (the pure OSC command parser). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +#ifndef TEST_OSC_H_ +#define TEST_OSC_H_ + +#include + +class TestOsc: public QObject +{ + Q_OBJECT + +private slots: + void rejectsNonMapMapAddress(); + void parsesGlobalTransport(); + void parsesSourceTransport(); + void parsesSourceProperty(); + void parsesSourceByNamePattern(); + void parsesLayerProperty(); + void acceptsMappingAlias(); + void parsesLayerMoveAndTranslate(); + void parsesLayerVertex(); + void rejectsMissingTarget(); + void rejectsMissingValue(); + void rejectsMalformedVertex(); + void ignoresLeadingAndDoubleSlashes(); +}; + +#endif /* TEST_OSC_H_ */ diff --git a/tests/main.cpp b/tests/main.cpp index d3bdaeb1..be793961 100644 --- a/tests/main.cpp +++ b/tests/main.cpp @@ -17,6 +17,7 @@ #include "TestUtil.h" #include "TestUidAllocator.h" #include "TestShape.h" +#include "TestOsc.h" int main(int argc, char** argv) { @@ -39,6 +40,10 @@ int main(int argc, char** argv) TestShape test; status |= QTest::qExec(&test, argc, argv); } + { + TestOsc test; + status |= QTest::qExec(&test, argc, argv); + } return status; } diff --git a/tests/tests.pro b/tests/tests.pro index 1716b722..d7b00a44 100644 --- a/tests/tests.pro +++ b/tests/tests.pro @@ -11,10 +11,11 @@ DEFINES += UNICODE QT_THREAD_SUPPORT QT_CORE_LIB QT_GUI_LIB QT_MESSAGELOGCONTEXT unix:!macx: DEFINES += UNIX win32: LIBS += -lopengl32 -CORE = $$PWD/../src/core -SHAPE = $$PWD/../src/shape +CORE = $$PWD/../src/core +SHAPE = $$PWD/../src/shape +CONTROL = $$PWD/../src/control -INCLUDEPATH += $$CORE $$SHAPE +INCLUDEPATH += $$CORE $$SHAPE $$CONTROL # Production sources under test plus the minimal set of dependencies they # need to link. We deliberately avoid pulling in the whole core/shape .pri @@ -34,10 +35,12 @@ HEADERS += \ $$SHAPE/Mesh.h \ $$SHAPE/Ellipse.h \ $$SHAPE/Shapes.h \ + $$CONTROL/OscAction.h \ TestMaths.h \ TestUtil.h \ TestUidAllocator.h \ - TestShape.h + TestShape.h \ + TestOsc.h SOURCES += \ $$CORE/MM.cpp \ @@ -49,8 +52,10 @@ SOURCES += \ $$SHAPE/Polygon.cpp \ $$SHAPE/Mesh.cpp \ $$SHAPE/Ellipse.cpp \ + $$CONTROL/OscAction.cpp \ main.cpp \ TestMaths.cpp \ TestUtil.cpp \ TestUidAllocator.cpp \ - TestShape.cpp + TestShape.cpp \ + TestOsc.cpp From 1ddce0c8a0bf10da771d2f9227a2a95b16d71bca Mon Sep 17 00:00:00 2001 From: Alexandre Quessy Date: Fri, 26 Jun 2026 01:11:11 -0400 Subject: [PATCH 3/3] Document the OSC interface and add a test client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite the OSC file: the old reference described addresses that no longer exist (/mapmap/paint/media/load). Document the actual address scheme — global transport, per-source and per-layer commands, the id/name selector convention, the coordinate space, and the security stance (no project file I/O over OSC). - Add scripts/mapmap-osc.py, a small dependency-free OSC client with automatic argument typing, for testing MapMap over OSC. - Update TODO: mark the implemented OSC callbacks as DONE, the ones still open as TODO, and the project load/save callbacks as WONTDO (security). --- OSC | 132 +++++++++++++++++++++++++++++++++++++----- TODO | 36 +++++++----- scripts/mapmap-osc.py | 107 ++++++++++++++++++++++++++++++++++ 3 files changed, 243 insertions(+), 32 deletions(-) create mode 100755 scripts/mapmap-osc.py diff --git a/OSC b/OSC index 81a82aea..95b15539 100644 --- a/OSC +++ b/OSC @@ -1,30 +1,130 @@ MapMap OSC Interface ==================== -API Reference -------------- +MapMap can be controlled remotely over `Open Sound Control +`_ (OSC) messages sent over UDP. This +is handy for live performance: a sequencer, a hardware controller bridged to +OSC, or another program can drive MapMap while a show runs. -/mapmap/paint/media/load -~~~~~~~~~~~~ -Change a media URI:: - - /mapmap/paint/media/load ,is +By default MapMap listens on UDP port **12345**. Change it from the +Preferences dialog, or at launch with ``mapmap --osc-port ``. -paintID: A number. Usually 0, or 1 or 2... depending on how many paints you have in your project. -path: Path to a file. -Examples +Conventions +----------- + +Every address starts with ``/mapmap``. Commands that act on a source or a +layer take the **target as their first argument**: + +* an **integer** selects a single element by its id, or +* a **string** selects every element whose name matches the given pattern + (shell-style wildcards, e.g. ``clip*``). + +"layer" and "mapping" are accepted as synonyms (a layer *is* a mapping). + +Coordinates (for ``move``, ``translate`` and ``vertex``) are expressed in +MapMap's internal coordinate space — the same numbers stored for each vertex +in the ``.mmp`` project file. These are pixel coordinates relative to the +canvas, with the origin at its top-left corner. Open a saved project to read +the actual values you want to target. + + +Global transport +----------------- + +:: + + /mapmap/play Start playback of every source. + /mapmap/pause Pause playback of every source. + /mapmap/rewind Rewind every source. + /mapmap/quit Quit MapMap. + + +Sources +------- + +```` is an integer id or a name pattern (see Conventions). + +:: + + /mapmap/source/play ,i|s Start playback of the source(s). + /mapmap/source/pause ,i|s Pause the source(s). + /mapmap/source/rewind ,i|s Rewind the source(s). + /mapmap/source/ ,i|s ... Set a property (see below). + +Settable source properties (````): + +:: + + opacity float 0.0 .. 1.0 + name string + uri string path of a video/image source + rate float playback rate, in % (negative = reverse) + volume float audio volume, in % (video sources) + color string "#rrggbb" or a colour name (color sources) + locked int/bool + +Examples:: + + /mapmap/source/opacity ,if 2 0.5 + /mapmap/source/color ,is 3 #ff0000 + /mapmap/source/uri ,is "Clip *" /home/vj/loops/a.mov + /mapmap/source/play ,i 2 + + +Layers +------ + +```` is an integer id or a name pattern (see Conventions). + +:: + + /mapmap/layer/ ,i|s ... Set a property. + /mapmap/layer/move/xy ,iff Move so the layer's centre is at (x, y). + /mapmap/layer/translate/xy ,iff Translate the layer by (dx, dy). + /mapmap/layer/vertex/xy ,iiff Set a destination-shape vertex. + /mapmap/layer/vertex/destination/xy ,iiff Same, explicit. + /mapmap/layer/vertex/source/xy ,iiff Set a source-shape vertex (texture layers). + +Settable layer properties (````): + +:: + + opacity float 0.0 .. 1.0 + visible int/bool + solo int/bool + locked int/bool + depth int + name string + +Examples:: + + /mapmap/layer/opacity ,if 0 0.8 + /mapmap/layer/visible ,ii 0 1 + /mapmap/layer/move/xy ,iff 0 640.0 360.0 + /mapmap/layer/translate/xy ,iff 0 -10.0 0.0 + /mapmap/layer/vertex/source/xy ,iiff 0 2 320.0 240.0 + + +Security -------- -Change a media path:: - - osc-send osc.udp://localhost:12345 /mapmap/paint/media/load ,is 0 ~/Videos/clips_finaux_ok/lys_flou_net.mov +There is no authentication on the OSC port. Anyone able to reach it can +control MapMap. If you do not want incoming messages during a show, block the +port at the firewall (or run MapMap on an isolated network). For the same +reason, MapMap deliberately does NOT expose loading or saving project files +over OSC. + See also -------- -You might consider using: +* ``scripts/mapmap-osc.py`` — a small, dependency-free OSC client shipped with + MapMap. Run ``python3 scripts/mapmap-osc.py --help``. +* The ``oscsend`` utility from `liblo `_. +* The ``python-osc`` library for Python. -* The txosc library for python. It contains osc-send -* The oscsend utility +Example with ``mapmap-osc.py``:: + python3 scripts/mapmap-osc.py /mapmap/layer/opacity 0 0.5 + python3 scripts/mapmap-osc.py --port 12345 /mapmap/source/color 3 '#ff0000' diff --git a/TODO b/TODO index b7576d1a..14c7968b 100644 --- a/TODO +++ b/TODO @@ -68,7 +68,7 @@ Fonctionnalités cool à faire plus tard -------------------------------------- * lignes des quads assignable on/off, voire animales, en midi * animation de stroke par groupe -* OSC pour controler les positions et l'alpha des quads +* DONE OSC pour controler les positions et l'alpha des quads (voir le fichier OSC) OSC INTERFACE ADDITIONAL CALLBACKS ---------------------------------- @@ -76,21 +76,25 @@ Instead of using boolean values (true or false) we use numbers, where 0 means fa You should make sure to block all incoming messages on that port if you don't want to be hacked during a show. -/mapmap/mapping/move/xy ,iff -/mapmap/mapping/vertex/source/xy ,iiff -/mapmap/mapping/vertex/destination/xy ,iiff -/mapmap/mapping/visible ,ii -/mapmap/mapping/highlight ,ii -/mapmap/mapping/vertex/highlight ,iii -/mapmap/project/load ,s -/mapmap/project/save ,s -/mapmap/paint/color/rgba ,iffff (each channel within [0,1]) -/mapmap/paint/media/load ,is -/mapmap/paint/media/speed ,if (1.0 means 100% speed) -/mapmap/paint/media/seek ,il