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 (in milliseconds)
-/mapmap/output/fullscreen ,i
-/mapmap/output/size ,ii
-/mapmap/output/position ,ii
+The full, current address scheme lives in the `OSC` file at the root of the
+repo. Status of the originally-wished-for callbacks below ("mapping" is now
+spelled "layer", but kept as an alias; "paint" is now "source"):
+
+DONE /mapmap/layer/move/xy ,iff
+DONE /mapmap/layer/vertex/source/xy ,iiff
+DONE /mapmap/layer/vertex/destination/xy ,iiff
+DONE /mapmap/layer/visible ,ii
+DONE /mapmap/source/color ,is <#rrggbb> (replaces paint/color/rgba)
+DONE /mapmap/source/uri ,is (replaces paint/media/load)
+DONE /mapmap/source/rate ,if (replaces paint/media/speed)
+TODO /mapmap/layer/highlight ,ii
+TODO /mapmap/layer/vertex/highlight ,iii
+TODO /mapmap/source/seek ,if (no seek API exposed yet)
+TODO /mapmap/output/fullscreen ,i
+TODO /mapmap/output/size ,ii
+TODO /mapmap/output/position ,ii
+WONTDO /mapmap/project/load ,s (file I/O over OSC is a security risk)
+WONTDO /mapmap/project/save ,s (file I/O over OSC is a security risk)
Roadmap (to do)
diff --git a/scripts/mapmap-osc.py b/scripts/mapmap-osc.py
new file mode 100755
index 00000000..ff246d32
--- /dev/null
+++ b/scripts/mapmap-osc.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+"""Tiny, dependency-free OSC client for driving MapMap over UDP.
+
+MapMap listens for OSC on UDP port 12345 by default (see the ``OSC`` file at
+the root of the repository for the full address scheme). This script encodes a
+single OSC message and sends it, with no third-party dependencies.
+
+Argument types are inferred automatically:
+
+* a token that parses as an integer is sent as an OSC int (``i``);
+* a token that parses as a float is sent as an OSC float (``f``);
+* anything else is sent as an OSC string (``s``).
+
+Prefix a token with ``i:``, ``f:`` or ``s:`` to force its type, e.g. ``s:12``
+sends the string "12" rather than the integer 12 (useful to select an element
+by a numeric name).
+
+Examples::
+
+ python3 scripts/mapmap-osc.py /mapmap/play
+ python3 scripts/mapmap-osc.py /mapmap/layer/opacity 0 0.5
+ python3 scripts/mapmap-osc.py /mapmap/layer/move/xy 0 640 360
+ python3 scripts/mapmap-osc.py /mapmap/source/color 3 '#ff0000'
+ python3 scripts/mapmap-osc.py --host 192.168.1.20 --port 9000 /mapmap/pause
+"""
+
+import argparse
+import socket
+import struct
+import sys
+
+
+def _osc_string(value: str) -> bytes:
+ """Encode an OSC string: UTF-8 bytes, null-terminated, padded to 4 bytes."""
+ data = value.encode("utf-8") + b"\x00"
+ if len(data) % 4:
+ data += b"\x00" * (4 - len(data) % 4)
+ return data
+
+
+def _encode_argument(token: str):
+ """Return (type_tag, packed_bytes) for one command-line argument token."""
+ forced = None
+ if len(token) > 1 and token[1] == ":" and token[0] in "ifs":
+ forced, token = token[0], token[2:]
+
+ if forced == "s":
+ return "s", _osc_string(token)
+ if forced == "i":
+ return "i", struct.pack(">i", int(token))
+ if forced == "f":
+ return "f", struct.pack(">f", float(token))
+
+ # Auto-detect: int, then float, then fall back to string.
+ try:
+ return "i", struct.pack(">i", int(token))
+ except ValueError:
+ pass
+ try:
+ return "f", struct.pack(">f", float(token))
+ except ValueError:
+ pass
+ return "s", _osc_string(token)
+
+
+def build_message(address: str, tokens) -> bytes:
+ """Build a complete OSC message packet from an address and argument tokens."""
+ tags = ","
+ payload = b""
+ for token in tokens:
+ tag, packed = _encode_argument(token)
+ tags += tag
+ payload += packed
+ return _osc_string(address) + _osc_string(tags) + payload
+
+
+def main(argv=None) -> int:
+ parser = argparse.ArgumentParser(
+ description="Send a single OSC message to MapMap.",
+ epilog=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument("address", help="OSC address, e.g. /mapmap/layer/opacity")
+ parser.add_argument("args", nargs="*", help="OSC arguments (types auto-detected)")
+ parser.add_argument("--host", default="127.0.0.1", help="target host (default: 127.0.0.1)")
+ parser.add_argument("--port", type=int, default=12345, help="target UDP port (default: 12345)")
+ parser.add_argument("-v", "--verbose", action="store_true", help="print what is sent")
+ options = parser.parse_args(argv)
+
+ if not options.address.startswith("/"):
+ parser.error("OSC address must start with '/' (e.g. /mapmap/play)")
+
+ packet = build_message(options.address, options.args)
+
+ if options.verbose:
+ print(f"-> {options.host}:{options.port} {options.address} {' '.join(options.args)}")
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ try:
+ sock.sendto(packet, (options.host, options.port))
+ finally:
+ sock.close()
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
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
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