MIT-licensed, Zig-powered implementation of the “Open Sound Control” (OSC) data format. OSC is a simple message format, similar to JSON or XML. The informal 1.1 specification sketches out the beginnings of a distinction between OSC as a content format and its most common use as a form of RPC (remote procedure call) between sound-aligned digital communicators.
The aim of this repository is to provide a conformant implementation of both the content format and the RPC mechanism in Zig, and exposing a C interface.
To add this package to your project, run this:
$ zig fetch --git+https://github.com/robbielyman/zosc#main
Then in your build.zig
you can add this:
const zosc = b.dependency("zosc", .{
.target = target,
.optimize = optimize,
});
// For whatever you’re building; in this case let’s assume it’s called exe.
exe.root_module.addImport("zosc", zosc.module("zosc"));
and in your source code:
// import
const zosc = @import("zosc");
// and use it something like this
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// messages are immutable once created; there are several options for creating a message
const msg = zosc.Message.fromTuple(allocator, "/some/path", .{ 1, 2, 3.14, "hello", true, false, null, .bang });
// messages are reference counted and destroyed when the count reaches 0
defer msg.unref();
// get the arguments of a message
const args = msg.getArgs(allocator);
defer allocator.free(args);
To build zOSC from source, clone this repository and execute zig build
in the repository root.
A pair of executables, zoscsend
and zoscdump
will be produced.
They allow for command-line sending and monitoring of OSC messages, respectively.
Running zig build
in the repository root will also compile a C library,
outputting a header in zig-out/include/zosc.h
and static and dynamic libraries in zig-out/lib
. A pkg-config
file is also provided.
The C library does not include an implementation of the RPC server,
but all the building blocks are there.
Essentially one should receive a block of bytes
via whatever protocol one wishes to communicate over (UDP is the most common),
attempt to process them into a zosc_bundle_iterator_t
and then a zosc_message_iterater_t
on failure,
or optionally create a reference-counted zosc_bundle_t
or zosc_message_t
from the byte block.
Either the owned type or the iterator (which does not own its content)
makes a good candidate as a function argument for a general “method” function type.
One can match against paths using zosc_match_path
and against type tags using zosc_match_types
to select appropriate methods to invoke.
Managing the list of methods feels out of scope for the C library.
The Zig library provides a bare-bones implementation,
but it might work best to roll ones own.
Below is a brief explanation of the library and its use.
A chunk of data may be parsed as OSC.
Valid OSC data starts either with the ASCII character ‘/’ or ‘#’
and occupies a variable number of bytes which should be divisible by four.
After creating or receiving such a chunk of data,
call parseOSC
, passing the data; for example
const z = @import("zosc");
var parsed = try z.parseOSC(bytes);
switch (parsed) {
.bundle => |*bundle| {
// the bundle variable is a z.Parse.BundleIterator;
// calling next on it will yield successive byte chunks until complete
},
.message => |*message| {
// the message variable is a z.Parse.MessageIterator;
// calling next on it will yield successive arguments
// from the message until complete
},
}
BundleIterator
and MessageIterator
objects do not allocate or own their content;
it is the callers job to ensure that the bytes argument to parseOSC
lives as long as the returned iterator does.
Arguments of the message are yielded as instances of the Data
union.
Pointer types in this union (s
, S
, b
) do not own their content,
which has a lifetime equal to the lifetime of the message content.
zOSC supports the following OSC data types:
- ‘i’: signed 32-bit integer
- ‘f’: 32-bit IEEE floating point number
- ’s’ and ‘S’: string types (the OSC specification requires that every byte be valid ASCII and not 0, but zOSC will accept any nonzero byte)
- ‘b’: “blob” data; an arbitrary number of bytes (up to the maximum value of an i32)
- ‘d’: 64-bit IEEE floating point number
- ‘h’: signed 64-bit integer
- ‘m’: 4 bytes, intended as MIDI data
- ‘c’: 1 byte character
- ‘r’ (this is present as an optional part of the OSC 1.0 specification and not present in the list of required types in the OSC 1.1 document, so is nonstandard for OSC): unsigned 32-bit integer, intended as RGBA color data
- ‘T’ and ‘F’: true and false, respectively
- ‘N’ and ‘I’: “nil” and “infinitum” or “bang”, respectively
- ‘t’: a timetag, with an unsigned 32-bit number of seconds since UTC 1900-01-01 and an unsigned 32-bit number of fractions of a second (on a scale of ~.1 nanosecond, so that 2^32 of these fractions is a whole second).
zOSC’s Message
and Bundle
types are immutable, reference-counted, owned types providing handles to OSC data.
There are several mechanisms for creating these objects
varying from raw data, to using Zig’s comptime to parse tuples,
to the Message.Builder
and Bundle.Builder
types,
which can be reused to commit
new messages while retaining their content.
Message
and Bundle
objects start with a reference count of 1
;
a user should call ref
to retain them and unref
when they are finished with them.
zosc.matchPath
and zosc.matchTypes
can be used to match a provided path or typetag against a pattern.
zOSC provides Zig users of the project a barebones implementation of the OSC RPC server protocol, as well as a way to create a standard “method” type from Zig functions of varying signature.
Here is a small example of an OSC server which will listen for UDP messages on port 1111.
const zosc = @import("zosc");
const std = @import("std");
pub fn main() !void {
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const addr = try std.net.Address.initIp4(.{ 127, 0, 0, 1 }, 1111);
const socket = try std.posix.socket(
addr.any.family,
std.posix.SOCK.CLOEXEC | std.posix.SOCK.DGRAM,
0,
);
defer std.posix.sock.close(socket);
try std.posix.setsockopt(socket, std.posix.SOL.SOCKET, std.posix.SO.REUSEPORT, &std.mem.toBytes(@as(c_int, 1)));
try std.posix.setsockopt(socket, std.posix.SOL.SOCKET, std.posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1)));
try std.posix.bind(socket, &addr.any, addr.getOsSockLen());
const buffer = try allocator.alloc(u8, 0xffff);
defer allocator.free(buffer);
var server = zosc.Server.init(allocator);
defer server.deinit();
_ = try server.register("/add", "ii", zosc.wrap(add), null);
_ = try server.register(null, null, defaultMethod, null);
while (true) {
const len = try std.posix.recv(socket, buffer, 0);
const msg = try zosc.Message.fromBytes(allocator, buffer[0..len]);
defer msg.unref();
try self.dispatch(msg.toBytes(), zosc.TimeTag.immediately);
}
}
fn add(_: ?*anyopaque, path: []const u8, a: i32, b: i32) !zosc.Continue {
std.debug.assert(std.mem.eql(u8, path, "/add"));
const stdout_file = std.io.getStdout().writer();
var bw = std.io.bufferedWriter(stdout_file);
const stdout = bw.writer();
try stdout.print("the sum of {d} and {d} is {d}\n", .{a, b, a + b});
try bw.flush();
return .no; // stops processing the message
}
fn defaultMethod(_: ?*anyopaque, iter: *zosc.MessageIterator) !zosc.Continue {
const stdout_file = std.io.getStdout().writer();
var bw = std.io.bufferedWriter(stdout_file);
const stdout = bw.writer();
try stdout.print("OSC message at path: {s}, types: {s}\n", .{iter.path, iter.types});
while (try iter.next()) |data| {
try stdout.print("OSC message argument: {}\n", .{data});
}
try bw.flush();
return .yes;
}