Copyright 2023 Travis J. West, https://traviswest.ca, Input Devices and Music Interaction Laboratory (IDMIL), Centre for Interdisciplinary Research in Music Media and Technology (CIRMMT), McGill University, Montréal, Canada, and Univ. Lille, Inria, CNRS, Centrale Lille, UMR 9189 CRIStAL, F-59000 Lille, France
SPDX-License-Identifier: LGPL-2.1-or-later
This document describes the implementation of the liblo OSC binding component.
TODO: this binding needs tests!
Overview
The liblo binding has the following responsibilities:
- Create at least one
lo_address
.
- Start and manage interaction with the liblo server thread
- including creating the thread with
lo_server_new
,
- registering an error handler callback with the same,
- registering at least one handler with
lo_server_add_method
,
- Route incoming messages to the appropriate endpoints, e.g. within a server method or methods
- Recognize when output signals have changed, and produce output messages accordingly using
lo_send
or another method
Limitations
Liblo requires the availability of POSIX networking and pthreads APIs. This is currently available on most personal computer operating systems, SOC embedded processors such as Raspberry Pi and Bela, as well as ESP32, but not on most MCU platforms. Due to discrepancies in the quality of the timebase on these platforms, especially those that may or may not have any form of clock synchronization available such as the ESP32, OSC functionality relying on accurate time stamps may not behave as intended.
Init
Server Setup
First, we wish to set up a liblo server.
This requires a port. We declare a text input to hold this parameter.
text_message< "source port"
, "The UDP port on which to receive incoming messages."
, tag_session_data
> src_port;
Setting up the server requires the source port and an error handler which we will describe momentarily. Liblo helpfully tries to find an unused port in case the server port argument is null; we can take advantage of this in case the user has not specified a source port. However, we have to take into account the possibility that this process will fail, or that the user will pass an invalid port number or one that is already taken. We signal such conditions with an output toggle.
toggle<"server running"> server_running;
To detect when the port is not set, or is invalid, we define a helper method that checks whether the port can be parsed as an int in the valid range for UPD ports. Ports less than 1024 are generally reserved by operating systems, so are not allowed here since they should generally not be used for OSC.
See the helpful list of TCP and UDP ports on Wikipedia for more information.
bool port_is_valid(auto& port)
{
int port_num = -1;
auto [ _, ec ] = std::from_chars(port->c_str(), port->c_str() + port->length(), port_num);
bool ret = ec == std::errc{} && (1024 <= port_num && port_num <= 65535);
if (not ret) fprintf(stderr, "liblo: invalid port %s (parsed as %d)\n", port->c_str(), port_num);
return ret;
}
TEST_CASE("sygaldry liblo osc port is valid")
{
LibloOsc<TestComponent> osc;
text_message<"s1"> s1{};
text_message<"s2"> s2{string("7777")};
text_message<"s3"> s3{string("77777")};
CHECK(not osc.port_is_valid(s1));
CHECK(osc.port_is_valid(s2));
CHECK(not osc.port_is_valid(s3));
}
Server setup begins by validating the input source port using this method. If the port has been updated and is valid, then we need to restart the server with the new port. However, if either or these conditions is not met (i.e. the port has not been updated or the port is not valid), and the server is already running, it would be inappropriate to restart the server, so we short circuit in this case.
void set_server(auto& components)
{
bool valid_user_src_port = port_is_valid(inputs.src_port);
if (outputs.server_running && not (inputs.src_port.updated && valid_user_src_port)) return;
fprintf(stdout, "liblo: setting up server\n");
Having checked that, we can be certain that we have everything we need to start setting up the server.
If the server already exists, we free it. The port must have been updated, so we need to create a new server instance with the new port.
We then create the new server instance, either allowing liblo to find an unused port (in case we don't have a valid one), or using the valid port given by the user.
if (server) lo_server_free(server);
if (not valid_user_src_port)
{
fprintf(stdout, "liblo: searching for unused port\n");
server = lo_server_new(NULL, &LibloOsc::server_error_handler);
}
else
{
fprintf(stdout, "liblo: using given port %s\n", inputs.src_port->c_str());
server = lo_server_new(inputs.src_port->c_str(), &LibloOsc::server_error_handler);
}
We check to make sure that worked as expected, signalling an error through the output flag in case of failure.
if (server == NULL)
{
fprintf(stderr, "liblo: server setup failed\n");
outputs.server_running = 0;
return;
}
fprintf(stderr, "liblo: server setup successful\n");
If we didn't already have a valid port, then liblo will have automatically found one. We set the input source port to this port. In either case, we then print the source port.
if (not valid_user_src_port)
{
char port_str[6] = {0};
int port_num = lo_server_get_port(server);
snprintf(port_str, 6, "%d", port_num);
inputs.src_port.value() = port_str;
clear_flag(inputs.src_port);
fprintf(stdout, "liblo: connected on port %s\n", inputs.src_port->c_str());
}
else
fprintf(stdout, "liblo: connected on port %s\n", inputs.src_port->c_str());
Finally, we register callbacks with the server and signal that it was successfully set up.
fprintf(stderr, "liblo: registering callbacks\n");
@{register callbacks}
fprintf(stderr, "liblo: done registering callbacks\n");
outputs.server_running = 1;
return;
}
During the initialization subroutine, we call this method after setting the server_running
flag low to prevent it from immediately returning without setting up the server.
fprintf(stdout, "liblo: initializing\n");
outputs.server_running = 0;
set_server(components);
The error handler simply logs the error. Liblo's error codes are inherited from whatever Posix network API implementation generates them, so it's very challenging to handle them. In normal use, errors should not occur.
static void server_error_handler(int num, const char *msg, const char *where)
{
fprintf(stderr, "liblo error: %s %s\n", msg, where);
}
Destination Address
Setting up the output liblo address is a similar matter, however, we require an IP address and port before we can create the lo_address
. Ideally we could have multiple destinations, but for now this is left as future work.
text_message< "destination port"
, "The UDP port on which to send outgoing messages."
, tag_session_data
> dst_port;
text_message< "destination address"
, "The IP address to send outgoing messages to."
, tag_session_data
> dst_addr;
We define a subroutine that we will call when all the necessary information is available; if the address and port are ready during initialization, the address will be set immediately. Otherwise, it will be run in the main subroutine when the address and port have been set.
As with the server setup method, we define a flag to signal whether the destination destination is set up, as well as methods to validate the inputs required to set up the destination address.
toggle<"output running"> output_running;
bool ip_addr_is_valid(auto& ip)
{
bool ret = ip->length() >= 7;
if (not ret) fprintf(stderr, "liblo: invalid IP address %s\n", ip->c_str());
return ret;
}
bool dst_inputs_are_valid()
{
return ip_addr_is_valid(inputs.dst_addr) and port_is_valid(inputs.dst_port);
}
The setup method is slightly simpler. If the destination parameters (IP address and port) have changed, we free any existing destination address and then reset a new one. We then simply set the output_running
flag according to whether this was successful.
void set_dst()
{
bool dst_updated = (inputs.dst_port.updated || inputs.dst_addr.updated);
if (not (dst_updated && dst_inputs_are_valid()) ) return;
fprintf(stdout, "liblo: setting destination address to %s:%s\n"
, inputs.dst_addr->c_str(), inputs.dst_port->c_str()
);
if (dst) lo_address_free(dst);
dst = lo_address_new(inputs.dst_addr->c_str(), inputs.dst_port->c_str());
if (dst) outputs.output_running = 1;
else
{
outputs.output_running = 0;
fprintf(stderr, "liblo: unable to set destination address\n");
}
}
outputs.output_running = 0;
set_dst();
Compared with setting up the server, we don't need to grab any information from liblo after setting up the destination address (because we have all relevant information before setting it up), and we don't have to register callbacks, which make this subroutine noticeably simpler.
Registering callbacks
There are broadly two appoaches that we could take for the server callback methods. On one hand, we could register one method for each input endpoint in the component tree. This may use more memory at runtime, as each method requires a dynamic allocation for a lo_method
struct. In exchange, liblo handles all of the pattern and argument type matching without our intervention. On the other hand, we could register a single callback, and perform the pattern matching and type checking ourselves, perhaps making use of liblo's pattern matching functionality. This may increase the compiled executable size, since our template-heavy framework is likely to generate a lot of similar code paths for each endpoint. For now, we opt for the former approach, as it enables us to get to a working implementation more quickly. If runtime memory becomes an issue, this may be one area where gains could be made.
For each input endpoint in the component tree, we register a handler
callback that defers setting the input to an overloaded template function set_input
that does most of the work; the handler
callback registered with liblo basically just holds the type information of the endpoint in the form of a type parameter that gets captured by this lambda.
for_each_input(components, [&]<typename T>(T& in)
{
fprintf(stdout, "liblo: registering callback for %s ... ", name_of(in));
constexpr auto handler = +[]( const char *path, const char *types
, lo_arg **argv, int argc, lo_message msg
, void *user_data
)
{
#ifndef NDEBUG
fprintf(stdout, "liblo: got message %s", path);
lo_message_pp(msg);
#endif
T& in = *(T*)user_data;
LibloOsc::set_input(path, types, argv, argc, msg, in);
return 0;
};
lo_server_add_method( server
, osc_path_v<T, Components>, osc_type_string_v<T>+1
, handler, (void*)&in
);
fprintf(stdout, "done\n");
});
The set_input
function simply inspects the endpoint and attempts to apply the OSC arguments to it. This implementation bears a lot of duplication that should be reduced in future work.
template<typename T> static void
set_input(const char *path, const char *types
, lo_arg **argv, int argc, lo_message msg
, T& in
)
{
if constexpr (Bang<T>) in = true;
else if constexpr (std::integral<value_t<T>>)
{
if (types[0] != 'i')
{
fprintf(stderr, "liblo: wrong type; expected 'i', got '%c'\n", types[0]);
return;
}
set_value(in, argv[0]->i);
}
else if constexpr (std::floating_point<value_t<T>>)
{
if (types[0] != 'f')
{
fprintf(stderr, "liblo: wrong type; expected 'f', got '%c'\n", types[0]);
return;
}
set_value(in, argv[0]->f);
}
else if constexpr (string_like<value_t<T>>)
{
if (types[0] != 's')
{
fprintf(stderr, "liblo: wrong type; expected 's', got '%c'\n", types[0]);
return;
}
set_value(in, &argv[0]->s);
}
else if constexpr (array_like<value_t<T>>)
{
for (std::size_t i = 0; i < size<value_t<T>>(); ++i)
{
auto& element = value_of(in)[i];
if constexpr (std::integral<element_t<T>>)
{
if (types[i] != 'i')
{
fprintf(stderr, "liblo: wrong type; expected 'i', got '%c'\n", types[i]);
return;
}
element = argv[i]->i;
}
else if constexpr (std::floating_point<element_t<T>>)
{
if (types[i] != 'f')
{
fprintf(stderr, "liblo: wrong type; expected 'f', got '%c'\n", types[i]);
return;
}
element = argv[i]->f;
}
else if constexpr (string_like<element_t<T>>)
{
if (types[i] != 's')
{
fprintf(stderr, "liblo: wrong type; expected 's', got '%c'\n", types[i]);
return;
}
element = &argv[i]->s;
}
}
if constexpr (UpdatedFlag<T>) in.updated = true;
}
if constexpr (has_name<T>) fprintf(stdout, "liblo: set input %s\n", name_of(in));
else fprintf(stdout, "liblo: set unnamed input\n");
};
Tick
On each tick, we perform three main tasks. External sources: We poll the server.
void external_sources()
{
if (outputs.server_running) lo_server_recv_noblock(server, 0);
}
Main: We update the server and destination address parameters if they have changed. TODO: this should happen in input callbacks
void main(Components& components)
{
set_server(components);
set_dst();
}
External destinations: we send output messages. This step is a bit more involved.
An earlier implementation of this subroutine simply called lo_send
and its relatives to send each output endpoint's current value as an OSC message. This results in a large number of calls to the underlying sockets API sendto
function, which was enough to overwhelm the ESP32 socket driver buffers. To avoid this, and likely improve performance on all platforms, we place our messages into a bundle so that only one socket sendto
is issued for each tick.
First, we only send messages if the output is running, i.e. we have a destination IP address and port number. If so, we populate the message with data from the endpoint. Once this is done, whatever messages were added are sent over the network.
void external_destinations(Components& components)
{
if (outputs.output_running)
{
lo_bundle bundle = lo_bundle_new(LO_TT_IMMEDIATE);
for_each_output(components, [&]<typename T>(T& output)
{
@{populate output messages}
});
int ret = lo_send_bundle(dst, bundle);
lo_bundle_free_recursive(bundle);
}
}
The way in which an output endpoint should be converted to OSC depends on its type. Bangs and occasional values only need to be sent when they have been updated. We check these types of endpoints for readiness before allocating a message.
For other types, if it's possible to perform an equality comparison, we only want to send data to the network with the value of the endpoint has changed. We take advantage of the fact that this is a template lambda and statically allocate a variable to hold the previous value of the endpoint with which to compare the current value.
Notice that the return statements here function as a way to continue the loop over output endpoints; they don't short circuit the overall external_destinations
subroutine.
if constexpr (OccasionalValue<T> || Bang<T>)
{
if (not flag_state_of(output))
return;
}
else if constexpr (requires (T t) {t == output;})
{
static T prev{};
if (output == prev)
return;
prev = output;
}
lo_message message = lo_message_new();
if (!message)
{
perror("liblo: unable to malloc new message. perror reports: \n");
return;
}
For endpoints with values, the message needs to be populated by calling lo_message_add
. We currently get the type tag string for one element in this process. Future work should move this to osc_string_constants.hpp
. Single valued endpoints are sent relatively simply; we just stuff the value of the endpoint into lo_message_add
. Array's are similar, requiring us to iterate over the array.
if constexpr (has_value<T> && not Bang<T>)
{
int ret = 0;
constexpr auto type = std::integral<element_t<T>> ? "i"
: std::floating_point<element_t<T>> ? "f"
: string_like<element_t<T>> ? "s" : "" ;
if constexpr (string_like<value_t<T>>)
ret = lo_message_add_string(message, value_of(output).c_str());
else if constexpr (array_like<value_t<T>>)
{
for (auto& element : value_of(output))
{
ret = lo_message_add(message, type, element);
if (ret < 0) break;
}
}
else ret = lo_message_add(message, type, value_of(output));
if (ret < 0)
{
lo_message_free(message);
return;
}
}
int ret = lo_bundle_add_message(bundle, osc_path_v<T, Components>, message);
if (ret < 0) fprintf(stderr, "liblo: unable to add message to bundle.\n");
return;
Liblo OSC Binding Summary
#pragma once
#include <stdio.h>
#include <charconv>
#include <lo/lo.h>
#include <lo/lo_lowlevel.h>
#include <lo/lo_types.h>
#include "sygac-metadata.hpp"
#include "sygac-endpoints.hpp"
#include "sygah-endpoints.hpp"
#include "sygbp-osc_string_constants.hpp"
namespace sygaldry { namespace sygbp {
template<typename Components>
struct LibloOsc
: name_<"Liblo OSC">
, author_<"Travis J. West">
, copyright_<"Copyright 2023 Travis J. West">
, license_<"SPDX-License-Identifier: LGPL-2.1-or-later">
, version_<"0.0.0">
, description_<"Open Sound Control bindings using the liblo library">
{
struct inputs_t {
@{inputs}
} inputs;
struct outputs_t {
@{outputs}
} outputs;
@{data members}
@{set_input}
@{set_server}
@{set_dst}
@{server_error_handler}
void init(Components& components)
{
@{init}
}
@{tick}
};
} }
#include <string>
#include <catch2/catch_test_macros.hpp>
#include "sygac-components.hpp"
#include "sygah-endpoints.hpp"
#include "sygbp-test_component.hpp"
#include "sygbp-liblo.hpp"
using std::string;
using namespace sygaldry;
using namespace sygaldry::sygbp;
@{tests}
# @#'CMakeLists.txt'
# Copyright 2023 Travis J. West, https://traviswest.ca, Input Devices and Music
# Interaction Laboratory (IDMIL), Centre for Interdisciplinary Research in Music
# Media and Technology (CIRMMT), McGill University, Montréal, Canada, and Univ.
# Lille, Inria, CNRS, Centrale Lille, UMR 9189 CRIStAL, F-59000 Lille, France
# SPDX-License-Identifier: LGPL-2.1-or-later
set(lib sygbp-liblo)
add_library(${lib} INTERFACE)
target_include_directories(${lib} INTERFACE .)
target_link_libraries(${lib}
INTERFACE sygac
INTERFACE sygah
INTERFACE sygbp-osc_string_constants
)
if (SYGALDRY_BUILD_TESTS)
# https://stackoverflow.com/questions/29191855/what-is-the-proper-way-to-use-pkg-config-from-cmake
find_package(PkgConfig REQUIRED)
pkg_check_modules(LIBLO REQUIRED liblo)
add_executable(${lib}-test ${lib}.test.cpp)
target_link_libraries(${lib}-test
PRIVATE Catch2::Catch2WithMain
PRIVATE ${LIBLO_LIBRARIES}
PRIVATE ${lib}
PRIVATE sygac-components
PRIVATE sygbp-test_component
)
target_include_directories(${lib}-test PRIVATE ${LIBLO_INCLUDE_DIRS})
catch_discover_tests(${lib}-test)
endif()
# @/