Skip to content

Commit 7dc4801

Browse files
committed
Learn to read OSC, not only write it
The first half of §8 item 6. §5.7 wants the app operable from a Stream Deck or Bitfocus Companion without touching the laptop, and nothing has ever listened: `OscSender` sends and `OscMessage` builds, and there was no way to read either back. `control::parseOsc` is the reading half. It shares no code with the building half on purpose — building is "append and pad", reading is "check everything the sender might have got wrong", and the two have nothing in common but the format. What it does share is the tests: the strongest thing that can be said about two halves of one format is that they are held to each other rather than to a test's idea of the spec. **Everything arriving here came off a socket**, which is to say from anywhere on the network, so the only answer to anything surprising is `std::nullopt`. Refused: a packet that is not a whole number of four-byte words, an address that does not start with '/' or is not terminated inside the packet, a type tag string without its comma, a tag whose width this does not know — the arguments after it could not be found, and guessing would mean acting on numbers nobody sent — an argument that runs past the end, and more than sixteen arguments, which are refused rather than truncated because silently dropping one is how a control surface ends up doing something other than what it said. Bundles are refused too: nothing in §5.7 needs one, and accepting a container of messages means deciding what their timestamps mean. Two things are deliberately lenient, both because the alternative is refusing real hardware. A message with nothing after its address is a message — OSC 1.0 wants the type tag string but plenty of senders omit it — and `T`/`F` count as numbers where §5.7 asks for `<0|1>`, because a Stream Deck sending a boolean means what one sending 1 means. The truncation test is the one that matters: every prefix of a real message, and every single-byte corruption of one at three values, has to be refused or parsed and never worse. A datagram cut short is the first thing anyone points at a listening socket. `net::udp` comes out of `osc_sender.cpp` ahead of the receiver needing it. A second copy of a reference-counted `WSAStartup` guard is the sort of duplication that is fine right up until one of them is fixed and the other is not. 190 tests with the UI off, up from 184. No behaviour changed: the sender is the same sender, now including the platform shim from a header instead of its own anonymous namespace. The socket, the dispatch table and learn mode are the other half, and `/ctl/lock` needs a decision before it can be written — see the handoff.
1 parent 5e43bfd commit 7dc4801

7 files changed

Lines changed: 571 additions & 80 deletions

File tree

‎src/core/CMakeLists.txt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,11 @@ add_library(takt4_core STATIC
3838
engine/control.hpp
3939
engine/live_tracker.cpp
4040
engine/live_tracker.hpp
41+
control/osc_parse.cpp
42+
control/osc_parse.hpp
4143
dsp/real_fft.cpp
4244
dsp/real_fft.hpp
45+
net/udp.hpp
4346
features/dimensions.hpp
4447
features/feature_extractor.cpp
4548
features/feature_extractor.hpp

‎src/core/control/osc_parse.cpp‎

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
#include "core/control/osc_parse.hpp"
2+
3+
#include <bit>
4+
#include <cstring>
5+
6+
namespace takt4::control {
7+
namespace {
8+
9+
/// OSC pads every part to a multiple of four bytes *after* its terminator, so a
10+
/// four-character address takes eight.
11+
constexpr std::size_t kAlign = 4;
12+
13+
std::size_t padded(std::size_t length) noexcept {
14+
return (length + kAlign) & ~(kAlign - 1);
15+
}
16+
17+
/// A null-terminated string starting at `offset`, and where the next part begins. Nothing
18+
/// when there is no terminator inside the packet — which is what a truncated datagram
19+
/// looks like, and the reason this cannot use `strlen`.
20+
struct Part {
21+
std::string_view text;
22+
std::size_t next = 0;
23+
};
24+
25+
std::optional<Part> readString(std::span<const std::byte> packet, std::size_t offset) noexcept {
26+
for (std::size_t i = offset; i < packet.size(); ++i) {
27+
if (packet[i] != std::byte{0}) {
28+
continue;
29+
}
30+
const std::size_t length = i - offset;
31+
const std::size_t next = offset + padded(length);
32+
if (next > packet.size()) {
33+
return std::nullopt; // the padding itself runs off the end
34+
}
35+
return Part{std::string_view(reinterpret_cast<const char*>(packet.data() + offset), length),
36+
next};
37+
}
38+
return std::nullopt;
39+
}
40+
41+
std::optional<std::uint32_t> readBig32(std::span<const std::byte> packet,
42+
std::size_t offset) noexcept {
43+
if (offset + 4 > packet.size()) {
44+
return std::nullopt;
45+
}
46+
return static_cast<std::uint32_t>(packet[offset]) << 24 |
47+
static_cast<std::uint32_t>(packet[offset + 1]) << 16 |
48+
static_cast<std::uint32_t>(packet[offset + 2]) << 8 |
49+
static_cast<std::uint32_t>(packet[offset + 3]);
50+
}
51+
52+
bool validAddress(std::string_view address) noexcept {
53+
if (address.empty() || address.front() != '/') {
54+
return false;
55+
}
56+
for (const char c : address) {
57+
if (c < ' ' || c > '~') {
58+
return false;
59+
}
60+
}
61+
return true;
62+
}
63+
64+
} // namespace
65+
66+
std::optional<double> OscView::number(std::size_t index) const noexcept {
67+
if (index >= count_) {
68+
return std::nullopt;
69+
}
70+
switch (arguments_[index].type) {
71+
case OscArgument::Type::Int32:
72+
return static_cast<double>(arguments_[index].asInt);
73+
case OscArgument::Type::Float32:
74+
return static_cast<double>(arguments_[index].asFloat);
75+
case OscArgument::Type::True:
76+
return 1.0;
77+
case OscArgument::Type::False:
78+
return 0.0;
79+
case OscArgument::Type::String:
80+
break;
81+
}
82+
return std::nullopt;
83+
}
84+
85+
std::optional<bool> OscView::flag(std::size_t index) const noexcept {
86+
const std::optional<double> value = number(index);
87+
if (!value) {
88+
return std::nullopt;
89+
}
90+
return *value != 0.0;
91+
}
92+
93+
std::optional<OscView> parseOsc(std::span<const std::byte> packet) noexcept {
94+
// A packet that is not a whole number of four-byte words cannot be OSC whatever else
95+
// is true of it, and checking first means nothing below has to handle a ragged end.
96+
if (packet.empty() || packet.size() % kAlign != 0) {
97+
return std::nullopt;
98+
}
99+
100+
const std::optional<Part> address = readString(packet, 0);
101+
if (!address || !validAddress(address->text)) {
102+
return std::nullopt;
103+
}
104+
// "#bundle" is a container of messages with a timestamp. Refused rather than guessed
105+
// at; see the header.
106+
if (address->text.front() == '#') {
107+
return std::nullopt;
108+
}
109+
110+
OscView view;
111+
view.address_ = address->text;
112+
if (address->next == packet.size()) {
113+
return view; // address only, no type tag string
114+
}
115+
116+
const std::optional<Part> tags = readString(packet, address->next);
117+
if (!tags || tags->text.empty() || tags->text.front() != ',') {
118+
return std::nullopt;
119+
}
120+
121+
std::size_t offset = tags->next;
122+
for (const char tag : tags->text.substr(1)) {
123+
if (view.count_ >= OscView::kMaxArguments) {
124+
return std::nullopt;
125+
}
126+
OscArgument& argument = view.arguments_[view.count_];
127+
switch (tag) {
128+
case 'i': {
129+
const std::optional<std::uint32_t> bits = readBig32(packet, offset);
130+
if (!bits) {
131+
return std::nullopt;
132+
}
133+
argument.type = OscArgument::Type::Int32;
134+
argument.asInt = std::bit_cast<std::int32_t>(*bits);
135+
offset += 4;
136+
break;
137+
}
138+
case 'f': {
139+
const std::optional<std::uint32_t> bits = readBig32(packet, offset);
140+
if (!bits) {
141+
return std::nullopt;
142+
}
143+
argument.type = OscArgument::Type::Float32;
144+
argument.asFloat = std::bit_cast<float>(*bits);
145+
offset += 4;
146+
break;
147+
}
148+
case 's': {
149+
const std::optional<Part> text = readString(packet, offset);
150+
if (!text) {
151+
return std::nullopt;
152+
}
153+
argument.type = OscArgument::Type::String;
154+
argument.asString = text->text;
155+
offset = text->next;
156+
break;
157+
}
158+
case 'T':
159+
argument.type = OscArgument::Type::True;
160+
break;
161+
case 'F':
162+
argument.type = OscArgument::Type::False;
163+
break;
164+
default:
165+
// Every other tag has a width this does not know, so the arguments after it
166+
// cannot be found. Guessing would mean acting on numbers nobody sent.
167+
return std::nullopt;
168+
}
169+
++view.count_;
170+
}
171+
return view;
172+
}
173+
174+
} // namespace takt4::control

‎src/core/control/osc_parse.hpp‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#pragma once
2+
3+
#include <cstddef>
4+
#include <cstdint>
5+
#include <optional>
6+
#include <span>
7+
#include <string_view>
8+
9+
namespace takt4::control {
10+
11+
/// HANDOFF §5.7's half of OSC: reading it.
12+
///
13+
/// `output::OscMessage` builds messages and this reads them, and they deliberately share
14+
/// no code — building is "append and pad", reading is "check everything the sender might
15+
/// have got wrong", and the two have almost nothing in common but the format.
16+
///
17+
/// **Everything arriving here came off a socket**, which is to say from anywhere on the
18+
/// network. A malformed datagram, a truncated one, a hostile one: the only acceptable
19+
/// answer is `std::nullopt`. Nothing here throws, allocates, or reads outside the span it
20+
/// was given.
21+
22+
/// One argument, as the type tag said it was. No conversions are invented: a receiver
23+
/// asking for an int from a string gets nothing.
24+
struct OscArgument {
25+
enum class Type : char {
26+
Int32 = 'i',
27+
Float32 = 'f',
28+
String = 's',
29+
True = 'T',
30+
False = 'F',
31+
};
32+
33+
Type type = Type::Int32;
34+
std::int32_t asInt = 0;
35+
float asFloat = 0.0f;
36+
/// Borrowed from the datagram, valid only while it is.
37+
std::string_view asString;
38+
};
39+
40+
/// A parsed OSC 1.0 message. Borrows the buffer it was parsed from: the address and any
41+
/// string arguments point into the datagram, which has to outlive this.
42+
class OscView {
43+
public:
44+
/// As many as `output::OscMessage` will build. A message with more is refused rather
45+
/// than truncated — silently dropping arguments is how a control surface ends up
46+
/// doing something other than what it said.
47+
static constexpr std::size_t kMaxArguments = 16;
48+
49+
std::string_view address() const noexcept { return address_; }
50+
std::size_t argumentCount() const noexcept { return count_; }
51+
const OscArgument& argument(std::size_t index) const noexcept { return arguments_[index]; }
52+
53+
/// The argument at `index` as a number, when it is one. `T` and `F` count: a Stream
54+
/// Deck sending a boolean means the same as one sending 1 or 0, and refusing it would
55+
/// be pedantry the operator pays for.
56+
std::optional<double> number(std::size_t index) const noexcept;
57+
58+
/// §5.7's `<0|1>` arguments. True for a non-zero number or `T`; false for zero or
59+
/// `F`; nothing for a message that carried no argument at all, which callers treat as
60+
/// "the sender did not say" rather than as false.
61+
std::optional<bool> flag(std::size_t index) const noexcept;
62+
63+
private:
64+
friend std::optional<OscView> parseOsc(std::span<const std::byte> packet) noexcept;
65+
66+
std::string_view address_;
67+
OscArgument arguments_[kMaxArguments];
68+
std::size_t count_ = 0;
69+
};
70+
71+
/// Parses one datagram, or returns nothing.
72+
///
73+
/// Refused: anything not a multiple of four bytes, an address that does not start with
74+
/// '/' or is not terminated inside the packet, a type tag string that does not start with
75+
/// ',', a tag this does not know (the argument's width would have to be guessed), an
76+
/// argument that runs past the end, and more than `kMaxArguments`.
77+
///
78+
/// A message with nothing after its address is accepted as one with no arguments. OSC 1.0
79+
/// requires the type tag string, but enough senders omit it for an address-only message
80+
/// that refusing them would mean refusing real control surfaces.
81+
///
82+
/// **Bundles are not handled** — `#bundle` returns nothing. Nothing in §5.7 needs one, and
83+
/// accepting a container of messages means deciding what their timestamps mean.
84+
std::optional<OscView> parseOsc(std::span<const std::byte> packet) noexcept;
85+
86+
} // namespace takt4::control

‎src/core/net/udp.hpp‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
#pragma once
2+
3+
/// The platform's UDP sockets, spelled the same way on all three.
4+
///
5+
/// Internal to `core`: this is a shim over `<winsock2.h>` and `<sys/socket.h>`, not an
6+
/// abstraction, and it pulls the platform's networking headers into whatever includes it.
7+
/// Nothing outside `core/output` and `core/control` should want it.
8+
///
9+
/// It exists because OSC now goes both ways — `output::OscSender` writes datagrams and
10+
/// `control::OscReceiver` reads them — and the second copy of a reference-counted
11+
/// `WSAStartup` guard is the sort of duplication that is right until one of them is
12+
/// fixed and the other is not.
13+
14+
#include <atomic>
15+
#include <string>
16+
17+
#if defined(_WIN32)
18+
#include <winsock2.h>
19+
// ws2tcpip.h must follow winsock2.h.
20+
#include <ws2tcpip.h>
21+
#pragma comment(lib, "ws2_32.lib")
22+
#else
23+
#include <arpa/inet.h>
24+
#include <netdb.h>
25+
#include <netinet/in.h>
26+
#include <sys/socket.h>
27+
#include <sys/types.h>
28+
#include <unistd.h>
29+
30+
#include <cerrno>
31+
#endif
32+
33+
#include <stdexcept>
34+
35+
namespace takt4::net {
36+
37+
#if defined(_WIN32)
38+
39+
using Socket = SOCKET;
40+
inline constexpr Socket kInvalidSocket = INVALID_SOCKET;
41+
42+
inline void closeSocket(Socket socket) noexcept {
43+
::closesocket(socket);
44+
}
45+
46+
inline int lastSocketError() noexcept {
47+
return ::WSAGetLastError();
48+
}
49+
50+
/// Winsock has to be started before any socket call and stopped after the last one.
51+
/// Everything holding a socket holds one of these, so the library is up exactly while at
52+
/// least one exists. Windows reference-counts `WSAStartup` itself; the counter here is so
53+
/// that `WSACleanup` is not called while another socket in this process is still open.
54+
class WinsockGuard {
55+
public:
56+
WinsockGuard() {
57+
if (count_.fetch_add(1, std::memory_order_acq_rel) == 0) {
58+
WSADATA data{};
59+
const int error = ::WSAStartup(MAKEWORD(2, 2), &data);
60+
if (error != 0) {
61+
count_.fetch_sub(1, std::memory_order_acq_rel);
62+
throw std::runtime_error("WSAStartup failed with " + std::to_string(error));
63+
}
64+
}
65+
}
66+
~WinsockGuard() {
67+
if (count_.fetch_sub(1, std::memory_order_acq_rel) == 1) {
68+
::WSACleanup();
69+
}
70+
}
71+
/// Copying takes its own reference rather than sharing one.
72+
WinsockGuard(const WinsockGuard&) : WinsockGuard() {}
73+
WinsockGuard& operator=(const WinsockGuard&) = delete;
74+
75+
private:
76+
inline static std::atomic<int> count_{0};
77+
};
78+
79+
#else
80+
81+
using Socket = int;
82+
inline constexpr Socket kInvalidSocket = -1;
83+
84+
inline void closeSocket(Socket socket) noexcept {
85+
::close(socket);
86+
}
87+
88+
inline int lastSocketError() noexcept {
89+
return errno;
90+
}
91+
92+
/// Nothing to start anywhere but Windows.
93+
struct WinsockGuard {};
94+
95+
#endif
96+
97+
/// "host:port" for an address, numeric, or "?" when it cannot be named. For logs and for
98+
/// a UI that has to say where a message came from.
99+
inline std::string describe(const sockaddr* address, socklen_t length) {
100+
char host[NI_MAXHOST] = {};
101+
char service[NI_MAXSERV] = {};
102+
if (::getnameinfo(address, length, host, sizeof host, service, sizeof service,
103+
NI_NUMERICHOST | NI_NUMERICSERV) != 0) {
104+
return "?";
105+
}
106+
return std::string(host) + ":" + service;
107+
}
108+
109+
} // namespace takt4::net

0 commit comments

Comments
 (0)